diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..34992fc7a0e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Protect the ownership rules themselves. +/.github/CODEOWNERS @steipete + +# Release workflow and its supporting release-path checks. +/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers +/docs/reference/RELEASING.md @openclaw/openclaw-release-managers +/scripts/openclaw-npm-publish.sh @openclaw/openclaw-release-managers +/scripts/openclaw-npm-release-check.ts @openclaw/openclaw-release-managers +/scripts/release-check.ts @openclaw/openclaw-release-managers diff --git a/.github/labeler.yml b/.github/labeler.yml index ffe55984ac6..91c202b7ed6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,6 @@ "channel: discord": - changed-files: - any-glob-to-any-file: - - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" "channel: irc": @@ -28,7 +27,6 @@ "channel: imessage": - changed-files: - any-glob-to-any-file: - - "src/imessage/**" - "extensions/imessage/**" - "docs/channels/imessage.md" "channel: line": @@ -64,19 +62,16 @@ "channel: signal": - changed-files: - any-glob-to-any-file: - - "src/signal/**" - "extensions/signal/**" - "docs/channels/signal.md" "channel: slack": - changed-files: - any-glob-to-any-file: - - "src/slack/**" - "extensions/slack/**" - "docs/channels/slack.md" "channel: telegram": - changed-files: - any-glob-to-any-file: - - "src/telegram/**" - "extensions/telegram/**" - "docs/channels/telegram.md" "channel: tlon": @@ -96,7 +91,6 @@ "channel: whatsapp-web": - changed-files: - any-glob-to-any-file: - - "src/web/**" - "extensions/whatsapp/**" - "docs/channels/whatsapp.md" "channel: zalo": diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00670107d00..a11e7331e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,9 @@ jobs: - runtime: node task: extensions command: pnpm test:extensions + - runtime: node + task: channels + command: pnpm test:channels - runtime: node task: protocol command: pnpm protocol:check diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index f4128cddc88..5eaba459957 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -12,9 +12,15 @@ on: - "**/*.mdx" - ".agents/**" - "skills/**" + workflow_dispatch: + inputs: + tag: + description: Existing release tag to backfill (for example v2026.3.13) + required: true + type: string concurrency: - group: docker-release-${{ github.workflow }}-${{ github.ref }} + group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} cancel-in-progress: false env: @@ -23,9 +29,48 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + validate_manual_backfill: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then + echo "Invalid release tag: ${RELEASE_TAG}" + exit 1 + fi + + - name: Checkout selected tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + approve_manual_backfill: + if: github.event_name == 'workflow_dispatch' + needs: validate_manual_backfill + # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT. + runs-on: ubuntu-24.04 + environment: docker-release + steps: + - name: Approve Docker backfill + env: + RELEASE_TAG: ${{ inputs.tag }} + run: echo "Approved Docker backfill for $RELEASE_TAG" + + # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. + # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. # Build amd64 images (default + slim share the build stage cache) build-amd64: - runs-on: blacksmith-16vcpu-ubuntu-2404 + needs: [approve_manual_backfill] + if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} + # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -35,6 +80,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + fetch-depth: 0 - name: Set up Docker Builder uses: docker/setup-buildx-action@v4 @@ -51,21 +99,22 @@ jobs: shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail tags=() slim_tags=() - if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-amd64") slim_tags+=("${IMAGE}:main-slim-amd64") fi - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - version="${GITHUB_REF#refs/tags/v}" + if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then + version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-amd64") slim_tags+=("${IMAGE}:${version}-slim-amd64") fi if [[ ${#tags[@]} -eq 0 ]]; then - echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" + echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { @@ -82,19 +131,22 @@ jobs: - name: Resolve OCI labels (amd64) id: labels shell: bash + env: + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail - version="${GITHUB_SHA}" - if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + source_sha="$(git rev-parse HEAD)" + version="${source_sha}" + if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - version="${GITHUB_REF#refs/tags/v}" + if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then + version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value</dev/null 2>&1; then + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error." + exit 0 + fi + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then + echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}" + else + echo "Previewing openclaw@${PACKAGE_VERSION}" + fi + + - name: Check + run: | + set -euxo pipefail + pnpm check + + - name: Build + run: | + set -euxo pipefail + pnpm build + + - name: Verify release contents + run: | + set -euxo pipefail + pnpm release:check + + - name: Preview publish command + run: bash scripts/openclaw-npm-publish.sh --dry-run + + publish_openclaw_npm: + if: github.event_name == 'workflow_dispatch' + # npm trusted publishing + provenance requires a GitHub-hosted runner. + runs-on: ubuntu-latest + environment: npm-release + permissions: + contents: read + id-token: write + steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then + echo "Invalid release tag format: ${RELEASE_TAG}" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Validate release tag and package metadata + env: + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_MAIN_REF: origin/main + run: | + set -euo pipefail + RELEASE_SHA=$(git rev-parse HEAD) + export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF # Fetch the full main ref so merge-base ancestry checks keep working # for older tagged commits that are still contained in main. git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main @@ -69,12 +192,4 @@ jobs: run: pnpm release:check - name: Publish - run: | - set -euo pipefail - PACKAGE_VERSION=$(node -p "require('./package.json').version") - - if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then - npm publish --access public --tag beta --provenance - else - npm publish --access public --provenance - fi + run: bash scripts/openclaw-npm-publish.sh --publish diff --git a/AGENTS.md b/AGENTS.md index 5112a8241df..0b1e17c8b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - 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. +- For targeted/local debugging, keep using the wrapper: `pnpm test -- [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing. - 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`. @@ -202,15 +203,43 @@ - Vocabulary: "makeup" = "mac app". - Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested. +- Parallels beta smoke: use `--target-package-spec openclaw@` for the beta artifact, and pin the stable side with both `--install-version ` and `--latest-version ` for upgrade runs. npm dist-tags can move mid-run. +- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane. - Parallels macOS smoke playbook: - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. - Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`. + - All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. - Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. - For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green. - Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially. - Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading. +- Parallels Windows smoke playbook: + - Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. + - Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path. + - Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy. + - Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. + - Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path. +- Parallels Linux smoke playbook: + - Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there. + - Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths. + - Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`. + - This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`. + - Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell. + - `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed. + - When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure. + - Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`. + - Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself. - Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. - When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). - Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c64548aa5a0..6b25a147e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,49 +6,96 @@ Docs: https://docs.openclaw.ai ### Changes -- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. -- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. -- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. -- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. -- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. +- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. +- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. ### Fixes -- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. +- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) +- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. + +### Fixes + +- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. +- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. +- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142) +- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) + +## 2026.3.13 + +### Changes + +- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. +- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. +- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. +- Browser/agents: add built-in `profile="user"` for the logged-in host browser and `profile="chrome-relay"` for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra `browserSession` selector. +- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. +- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. +- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`. +- Cron/sessions: add `sessionTarget: "current"` and `session:` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF. +- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent. + +### Breaking + +- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei. + +### Fixes + +- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev. +- Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging `GatewayClient.request()` promises indefinitely. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. -- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. - Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. -- Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. -- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. -- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. -- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. -- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. -- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. -- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. -- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. -- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. -- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. -- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart. -- Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. - Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. -- Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. -- Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. -- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. +- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0. +- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. +- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. +- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei. +- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. +- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green. +- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens. +- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. +- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images. +- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. +- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart. +- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. +- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. +- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. +- Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. +- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. +- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. +- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. +- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. +- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. +- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo. +- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu. +- Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to `google-vertex` model refs and provider configs so `google-vertex/gemini-3.1-flash-lite` resolves as `gemini-3.1-flash-lite-preview`. (#42435) thanks @scoootscooob. +- iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua. +- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. +- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. +- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. - Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants. - Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token. - Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks. - Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins. -- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization. -- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. -- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. +- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. +- Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. +- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. +- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. +- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. - Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin. -- Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. -- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. -- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. -- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. +- Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. +- Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. +- Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. +- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. +- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. +- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. +- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix +- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931) +- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) ## 2026.3.12 @@ -118,13 +165,16 @@ Docs: https://docs.openclaw.ai - Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman. - Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman. - Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev. +- Config/Anthropic startup: inline Anthropic alias normalization during config load so gateway startup no longer crashes on dated Anthropic model refs like `anthropic/claude-sonnet-4-20250514`. (#45520) Thanks @BunsDev. - Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz. - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures. - Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666. - Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras. - Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras. +- Browser/existing-session: stop reporting fake CDP ports/URLs for live attached Chrome sessions, render `transport: chrome-mcp` in CLI/status output instead of `port: 0`, and keep timeout diagnostics transport-aware when no direct CDP URL exists. - Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. +- Feishu/event dedupe: keep early duplicate suppression aligned with the shared Feishu message-id contract and release the pre-queue dedupe marker after failed dispatch so retried events can recover instead of being dropped until the short TTL expires. (#43762) Thanks @yunweibang. - Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted. - Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI. - Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. @@ -136,6 +186,7 @@ Docs: https://docs.openclaw.ai - CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621. - Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte. - Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh. +- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu. ## 2026.3.11 @@ -276,6 +327,8 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke. - Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. - Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. +- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. +- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. ## 2026.3.8 @@ -3279,7 +3332,7 @@ Docs: https://docs.openclaw.ai - Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. - Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566) - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) -- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998) +- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (#45459) Thanks @LyttonFeng and @vincentkoc. - Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87ccbeff4ef..0febbf5ec89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ Welcome to the lobster tank! 🦞 - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) -- **Radek Sienkiewicz** - Control UI + WebChat correctness +- **Radek Sienkiewicz** - Docs, Control UI - GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark) - **Muhammed Mukhthar** - Mattermost, CLI @@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞 - **Tengji (George) Zhang** - Chinese model APIs, cloud, pi - GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z) +- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT + - GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk) + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! diff --git a/Dockerfile b/Dockerfile index 72c413ebe7b..57a3440f385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,6 +132,7 @@ WORKDIR /app RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ procps hostname curl git openssl diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 8b50c7a6745..37cdab5fcd2 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index f04e4a82a62..e8e8bb59f84 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common index 39eaa3692b4..fba29a5df3d 100644 --- a/Dockerfile.sandbox-common +++ b/Dockerfile.sandbox-common @@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends ${PACKAGES} RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi diff --git a/Swabble/Sources/SwabbleKit/WakeWordGate.swift b/Swabble/Sources/SwabbleKit/WakeWordGate.swift index 27c952a8d1b..1a1479b630b 100644 --- a/Swabble/Sources/SwabbleKit/WakeWordGate.swift +++ b/Swabble/Sources/SwabbleKit/WakeWordGate.swift @@ -101,25 +101,19 @@ public enum WakeWordGate { } public static func commandText( - transcript: String, + transcript _: String, segments: [WakeWordSegment], triggerEndTime: TimeInterval) -> String { let threshold = triggerEndTime + 0.001 + var commandWords: [String] = [] + commandWords.reserveCapacity(segments.count) for segment in segments where segment.start >= threshold { - if normalizeToken(segment.text).isEmpty { continue } - if let range = segment.range { - let slice = transcript[range.lowerBound...] - return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation) - } - break + let normalized = normalizeToken(segment.text) + if normalized.isEmpty { continue } + commandWords.append(segment.text) } - - let text = segments - .filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty } - .map(\.text) - .joined(separator: " ") - return text.trimmingCharacters(in: Self.whitespaceAndPunctuation) + return commandWords.joined(separator: " ").trimmingCharacters(in: Self.whitespaceAndPunctuation) } public static func matchesTextOnly(text: String, triggers: [String]) -> Bool { diff --git a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift b/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift index 5cc283c35ae..7e5b4abdd74 100644 --- a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift +++ b/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift @@ -46,6 +46,25 @@ import Testing let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) #expect(match?.command == "do it") } + + @Test func commandTextHandlesForeignRangeIndices() { + let transcript = "hey clawd do thing" + let other = "do thing" + let foreignRange = other.range(of: "do") + let segments = [ + WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")), + WakeWordSegment(text: "clawd", start: 0.2, duration: 0.1, range: transcript.range(of: "clawd")), + WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange), + WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil), + ] + + let command = WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: 0.3) + + #expect(command == "do thing") + } } private func makeSegments( diff --git a/appcast.xml b/appcast.xml index 69632c08b97..c1919972b22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,82 @@ OpenClaw + + 2026.3.13 + Sat, 14 Mar 2026 05:19:48 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2026031390 + 2026.3.13 + 15.0 + OpenClaw 2026.3.13 +

Changes

+
    +
  • Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
  • +
  • iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show /pair qr instructions on the connect step. (#45054) Thanks @ngutman.
  • +
  • Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for chrome://inspect/#remote-debugging enablement and direct backlinks to Chrome’s own setup guides.
  • +
  • Browser/agents: add built-in profile="user" for the logged-in host browser and profile="chrome-relay" for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra browserSession selector.
  • +
  • Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
  • +
  • Docker/timezone override: add OPENCLAW_TZ so docker-setup.sh can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
  • +
  • Dependencies/pi: bump @mariozechner/pi-agent-core, @mariozechner/pi-ai, @mariozechner/pi-coding-agent, and @mariozechner/pi-tui to 0.58.0.
  • +
+

Fixes

+
    +
  • Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
  • +
  • Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging GatewayClient.request() promises indefinitely.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • Ollama/reasoning visibility: stop promoting native thinking and reasoning fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
  • +
  • Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.
  • +
  • Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.
  • +
  • Browser/existing-session: accept text-only list_pages and new_page responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
  • +
  • Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
  • +
  • Gateway/session reset: preserve lastAccountId and lastThreadId across gateway session resets so replies keep routing back to the same account and thread after /reset. (#44773) Thanks @Lanfei.
  • +
  • macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so openclaw onboard --install-daemon no longer false-fails on slower Macs and fresh VM snapshots.
  • +
  • Gateway/status: add openclaw gateway status --require-rpc and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.
  • +
  • macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered system.run requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
  • +
  • Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
  • +
  • Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
  • +
  • Windows/gateway install: bound schtasks calls and fall back to the Startup-folder login item when task creation hangs, so native openclaw gateway install fails fast instead of wedging forever on broken Scheduled Task setups.
  • +
  • Windows/gateway stop: resolve Startup-folder fallback listeners from the installed gateway.cmd port, so openclaw gateway stop now actually kills fallback-launched gateway processes before restart.
  • +
  • Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in gateway status --json instead of falling back to gateway port unknown.
  • +
  • Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale device signature expired fallback noise before succeeding.
  • +
  • Discord/gateway startup: treat plain-text and transient /gateway/bot metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
  • +
  • Slack/probe: keep auth.test() bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.
  • +
  • Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
  • +
  • Dashboard/chat UI: restore the chat-new-messages class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.
  • +
  • Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
  • +
  • macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
  • +
  • Discord/allowlists: honor raw guild_id when hydrated guild objects are missing so allowlisted channels and threads like #maintainers no longer get false-dropped before channel allowlist checks.
  • +
  • macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
  • +
  • Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.
  • +
  • Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to google-vertex model refs and provider configs so google-vertex/gemini-3.1-flash-lite resolves as gemini-3.1-flash-lite-preview. (#42435) thanks @scoootscooob.
  • +
  • iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.
  • +
  • Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
  • +
  • Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
  • +
  • Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed EXTERNAL_UNTRUSTED_CONTENT markers fall back to the existing hardening path instead of bypassing marker normalization.
  • +
  • Security/exec approvals: unwrap more pnpm runtime forms during approval binding, including pnpm --reporter ... exec and direct pnpm node file runs, with matching regression coverage and docs updates.
  • +
  • Security/exec approvals: fail closed for Perl -M and -I approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
  • +
  • Security/exec approvals: recognize PowerShell -File and -f wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing -Command variants.
  • +
  • Security/exec approvals: unwrap env dispatch wrappers inside shell-segment allowlist resolution on macOS so env FOO=bar /path/to/bin resolves against the effective executable instead of the wrapper token.
  • +
  • Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued $( substitutions fail closed instead of slipping past command-substitution checks.
  • +
  • Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.
  • +
  • Agents/OpenAI-compatible compat overrides: respect explicit user models[].compat opt-ins for non-native openai-completions endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.
  • +
  • Agents/Azure OpenAI startup prompts: rephrase the built-in /new, /reset, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.
  • +
  • Agents/memory bootstrap: load only one root memory file, preferring MEMORY.md and using memory.md as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.
  • +
  • Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
  • +
  • Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.
  • +
  • Agents/tool warnings: distinguish gated core tools like apply_patch from plugin-only unknown entries in tools.profile warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.
  • +
  • Config/validation: accept documented agents.list[].params per-agent overrides in strict config validation so openclaw config validate no longer rejects runtime-supported cacheRetention, temperature, and maxTokens settings. (#41171) Thanks @atian8179.
  • +
  • Config/web fetch: restore runtime validation for documented tools.web.fetch.readability and tools.web.fetch.firecrawl settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.
  • +
  • Signal/config validation: add channels.signal.groups schema support so per-group requireMention, tools, and toolsBySender overrides no longer get rejected during config validation. (#27199) Thanks @unisone.
  • +
  • Config/discovery: accept discovery.wideArea.domain in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.
  • +
  • Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
  • +
+

View full changelog

+]]>
+ +
2026.3.12 Fri, 13 Mar 2026 04:25:50 +0000 @@ -168,367 +244,5 @@ ]]> - - 2026.3.7 - Sun, 08 Mar 2026 04:42:35 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026030790 - 2026.3.7 - 15.0 - OpenClaw 2026.3.7 -

Changes

-
    -
  • Agents/context engine plugin interface: add ContextEngine plugin slot with full lifecycle hooks (bootstrap, ingest, assemble, compact, afterTurn, prepareSubagentSpawn, onSubagentEnded), slot-based registry with config-driven resolution, LegacyContextEngine wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via AsyncLocalStorage, and sessions.get gateway method. Enables plugins like lossless-claw to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.
  • -
  • ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.
  • -
  • Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in /acp spawn, support Telegram topic thread binding (--thread here|auto), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.
  • -
  • Telegram/topic agent routing: support per-topic agentId overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
  • -
  • Web UI/i18n: add Spanish (es) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.
  • -
  • Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.
  • -
  • Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.
  • -
  • Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.
  • -
  • Docker/Podman extension dependency baking: add OPENCLAW_EXTENSIONS so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.
  • -
  • Plugins/before_prompt_build system-context fields: add prependSystemContext and appendSystemContext so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.
  • -
  • Plugins/hook policy: add plugins.entries..hooks.allowPromptInjection, validate unknown typed hook names at runtime, and preserve legacy before_agent_start model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.
  • -
  • Hooks/Compaction lifecycle: emit session:compact:before and session:compact:after internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.
  • -
  • Agents/compaction post-context configurability: add agents.defaults.compaction.postCompactionSections so deployments can choose which AGENTS.md sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.
  • -
  • TTS/OpenAI-compatible endpoints: add messages.tts.openai.baseUrl config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.
  • -
  • Slack/DM typing feedback: add channels.slack.typingReaction so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
  • -
  • Discord/allowBots mention gating: add allowBots: "mentions" to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.
  • -
  • Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
  • -
  • Cron/job snapshot persistence: skip backup during normalization persistence in ensureLoaded so jobs.json.bak keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
  • -
  • CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
  • -
  • Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.
  • -
  • Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
  • -
  • Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
  • -
  • Config/Compaction safeguard tuning: expose agents.defaults.compaction.recentTurnsPreserve and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.
  • -
  • iOS/App Store Connect release prep: align iOS bundle identifiers under ai.openclaw.client, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.
  • -
  • Mattermost/model picker: add Telegram-style interactive provider/model browsing for /oc_model and /oc_models, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
  • -
  • Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add OPENCLAW_VARIANT=slim build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
  • -
  • Google/Gemini 3.1 Flash-Lite: add first-class google/gemini-3.1-flash-lite-preview support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
  • -
-

Breaking

-
    -
  • BREAKING: Gateway auth now requires explicit gateway.auth.mode when both gateway.auth.token and gateway.auth.password are configured (including SecretRefs). Set gateway.auth.mode to token or password before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
  • -
-

Fixes

-
    -
  • Models/MiniMax: stop advertising removed MiniMax-M2.5-Lightning in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as MiniMax-M2.5-highspeed.
  • -
  • Security/Config: fail closed when loadConfig() hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.
  • -
  • Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in bm25RankToScore() so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.
  • -
  • LINE/requireMention group gating: align inbound and reply-stage LINE group policy resolution across raw, group:, and room: keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
  • -
  • Onboarding/local setup: default unset local tools.profile to coding instead of messaging, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.
  • -
  • Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)
  • -
  • Onboarding/headless Linux daemon probe hardening: treat systemctl --user is-enabled probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.
  • -
  • Memory/QMD mcporter Windows spawn hardening: when mcporter.cmd launch fails with spawn EINVAL, retry via bare mcporter shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
  • -
  • Tools/web_search Brave language-code validation: align search_lang handling with Brave-supported codes (including zh-hans, zh-hant, en-gb, and pt-br), map common alias inputs (zh, ja) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.
  • -
  • Models/openai-completions streaming compatibility: force compat.supportsUsageInStreaming=false for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering choices[0] parser crashes in provider streams. (#8714) Thanks @nonanon1.
  • -
  • Tools/xAI native web-search collision guard: drop OpenClaw web_search from tool registration when routing to xAI/Grok model providers (including OpenRouter x-ai/*) to avoid duplicate tool-name request failures against provider-native web_search. (#14749) Thanks @realsamrat.
  • -
  • TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane.
  • -
  • WhatsApp/self-chat response prefix fallback: stop forcing "[openclaw]" as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor.
  • -
  • Memory/QMD search result decoding: accept qmd search hits that only include file URIs (for example qmd://collection/path.md) without docid, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty memory_search output. (#28181) Thanks @0x76696265.
  • -
  • Memory/QMD collection-name conflict recovery: when qmd collection add fails because another collection already occupies the same path + pattern, detect the conflicting collection from collection list, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.
  • -
  • Slack/app_mention race dedupe: when app_mention dispatch wins while same-ts message prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.
  • -
  • Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.
  • -
  • TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so /model updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.
  • -
  • TUI/final-error rendering fallback: when a chat final event has no renderable assistant content but includes envelope errorMessage, render the formatted error text instead of collapsing to "(no output)", preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.
  • -
  • TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example agent::main vs main) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.
  • -
  • OpenAI Codex OAuth/login parity: keep openclaw models auth login --provider openai-codex on the built-in path even without provider plugins, preserve Pi-generated authorize URLs without local scope rewriting, and stop validating successful Codex sign-ins against the public OpenAI Responses API after callback. (#37558; follow-up to #36660 and #24720) Thanks @driesvints, @Skippy-Gunboat, and @obviyus.
  • -
  • Agents/config schema lookup: add gateway tool action config.schema.lookup so agents can inspect one config path at a time before edits without loading the full schema into prompt context. (#37266) Thanks @gumadeiras.
  • -
  • Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header ByteString construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
  • -
  • Kimi Coding/Anthropic tools compatibility: normalize anthropic-messages tool payloads to OpenAI-style tools[].function + compatible tool_choice when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
  • -
  • Heartbeat/workspace-path guardrails: append explicit workspace HEARTBEAT.md path guidance (and docs/heartbeat.md avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
  • -
  • Subagents/kill-complete announce race: when a late subagent-complete lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
  • -
  • Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic missing tool result entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
  • -
  • Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream terminated failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.
  • -
  • Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for rate_limit (instead of failing pre-run as No available auth profile), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura.
  • -
  • Cron/OpenAI Codex OAuth refresh hardening: when openai-codex token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.
  • -
  • TUI/session isolation for /new: make /new allocate a unique tui- session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize /new and /reset failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber.
  • -
  • Synology Chat/rate-limit env parsing: honor SYNOLOGY_RATE_LIMIT=0 as an explicit value while still falling back to the default limit for malformed env values instead of partially parsing them. Landed from contributor PR #39197 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI Realtime STT config defaults: honor explicit vadThreshold: 0 and silenceDurationMs: 0 instead of silently replacing them with defaults. Landed from contributor PR #39196 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI TTS speed config: honor explicit speed: 0 instead of silently replacing it with the default speed. Landed from contributor PR #39318 by @ql-wade. Thanks @ql-wade.
  • -
  • launchd/runtime PID parsing: reject pid <= 0 from launchctl print so the daemon state parser no longer treats kernel/non-running sentinel values as real process IDs. Landed from contributor PR #39281 by @mvanhorn. Thanks @mvanhorn.
  • -
  • Cron/file permission hardening: enforce owner-only (0600) cron store/backup/run-log files and harden cron store + run-log directories to 0700, including pre-existing directories from older installs. (#36078) Thanks @aerelune.
  • -
  • Gateway/remote WS break-glass hostname support: honor OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for ws:// hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
  • -
  • Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second resolveAgentRoute stalls in large binding configurations. (#36915) Thanks @songchenghao.
  • -
  • Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during sessions.reset/sessions.delete runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.
  • -
  • Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures.
  • -
  • Slack/local file upload allowlist parity: propagate mediaLocalRoots through the Slack send action pipeline so workspace-rooted attachments pass assertLocalMediaAllowed checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.
  • -
  • Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
  • -
  • Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent RangeError: Invalid string length on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
  • -
  • iMessage/cron completion announces: strip leaked inline reply tags (for example [[reply_to:6100]]) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
  • -
  • Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
  • -
  • Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
  • -
  • Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example @Bot/model and @Bot /reset) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
  • -
  • Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false device token mismatch disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
  • -
  • Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
  • -
  • Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
  • -
  • Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so /agents no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.
  • -
  • Gateway/auth follow-up hardening: preserve systemd EnvironmentFile= precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.
  • -
  • Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing thinking/text strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
  • -
  • Agents/transcript policy: set preserveSignatures to Anthropic-only handling in resolveTranscriptPolicy so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
  • -
  • Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok Invalid arguments failures. (openclaw#35355) thanks @Sid-Qin.
  • -
  • Skills/native command deduplication: centralize skill command dedupe by canonical skillName in listSkillCommandsForAgents so duplicate suffixed variants (for example _2) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
  • -
  • Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (&, ", <, >, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
  • -
  • Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and systemctl --user is-enabled failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.
  • -
  • Linux/systemd status and degraded-session handling: treat degraded-but-reachable systemctl --user status results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of not installed. Thanks @vincentkoc.
  • -
  • Agents/thinking-tag promotion hardening: guard promoteThinkingTagsToBlocks against malformed assistant content entries (null/undefined) before block.type reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
  • -
  • Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid dev placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap serverVersion to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
  • -
  • Control UI/markdown parser crash fallback: catch marked.parse() failures and fall back to escaped plain-text
     rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
  • -
  • Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
  • -
  • Web UI/config form: treat additionalProperties: true object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
  • -
  • Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread message.reply routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
  • -
  • Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so requireMention checks compare against current bot identity instead of stale config names, fixing missed @bot handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
  • -
  • Security/dependency audit: patch transitive Hono vulnerabilities by pinning hono to 4.12.5 and @hono/node-server to 1.19.10 in production resolution paths. Thanks @shakkernerd.
  • -
  • Security/dependency audit: bump tar to 7.5.10 (from 7.5.9) to address the high-severity hardlink path traversal advisory (GHSA-qffp-2rhf-9h96). Thanks @shakkernerd.
  • -
  • Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.
  • -
  • Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after cron announce delivery failed warnings.
  • -
  • Auto-reply/system events: restore runtime system events to the message timeline (System: lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.
  • -
  • Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for accounts. (#34982) Thanks @HOYALIM.
  • -
  • Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.
  • -
  • Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with max_completion_tokens or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc.
  • -
  • Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session totalTokens from real usage instead of stale prior values. (#34275) thanks @RealKai42.
  • -
  • Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM To=user:* sessions (including toolContext.currentChannelId fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax.
  • -
  • Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204.
  • -
  • Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared rawCommand, and cover the system.run.prepare -> system.run handoff so direct PATH-based nodes.run commands no longer fail with rawCommand does not match command. (#33137) thanks @Sid-Qin.
  • -
  • Models/custom provider headers: propagate models.providers..headers across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.
  • -
  • Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured models.providers.ollama entries that omit apiKey, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs.
  • -
  • Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.
  • -
  • Ollama/compaction and summarization: register custom api: "ollama" handling for compaction, branch-style internal summarization, and TTS text summarization on current main, so native Ollama models no longer fail with No API provider registered for api: ollama outside the main run loop. Thanks @JaviLib.
  • -
  • Daemon/systemd install robustness: treat systemctl --user is-enabled exit-code-4 not-found responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with systemctl is-enabled unavailable. (#33634) Thanks @Yuandiaodiaodiao.
  • -
  • Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to agent:main. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.
  • -
  • Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native markdown_text in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931)
  • -
  • Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct /tools/invoke clients by allowing media nodes invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.
  • -
  • Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root.
  • -
  • Agents/Nodes media outputs: add dedicated photos_latest action handling, block media-returning nodes invoke commands, keep metadata-only camera.list invoke allowed, and normalize empty photos_latest results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.
  • -
  • TUI/session-key canonicalization: normalize openclaw tui --session values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.
  • -
  • iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.
  • -
  • Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or SKILL.md files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.
  • -
  • Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
  • -
  • gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.
  • -
  • Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.
  • -
  • Sessions/subagent attachments: remove attachments[].content.maxLength from sessions_spawn schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
  • -
  • Runtime/tool-state stability: recover from dangling Anthropic tool_use after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
  • -
  • ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.
  • -
  • Extensions/media local-root propagation: consistently forward mediaLocalRoots through extension sendMedia adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
  • -
  • Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration.
  • -
  • Feishu/video media send contract: keep mp4-like outbound payloads on msg_type: "media" (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.
  • -
  • Gateway/security default response headers: add Permissions-Policy: camera=(), microphone=(), geolocation=() to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
  • -
  • Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into openclaw/plugin-sdk/core and openclaw/plugin-sdk/telegram, and preserve api.runtime reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
  • -
  • Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root openclaw/plugin-sdk compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.
  • -
  • Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
  • -
  • Gateway/password CLI hardening: add openclaw gateway run --password-file, warn when inline --password is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.
  • -
  • Config/heartbeat legacy-path handling: auto-migrate top-level heartbeat into agents.defaults.heartbeat (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
  • -
  • Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.
  • -
  • Google/Gemini Flash model selection: switch built-in gemini-flash defaults and docs/examples from the nonexistent google/gemini-3.1-flash-preview ID to the working google/gemini-3-flash-preview, while normalizing legacy OpenClaw config that still uses the old Flash 3.1 alias.
  • -
  • Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic openclaw/plugin-sdk imports to scoped subpaths (or openclaw/plugin-sdk/core) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root openclaw/plugin-sdk support for external/community plugins. Thanks @gumadeiras.
  • -
  • Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.
  • -
  • Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (agent::: and ...:thread:) so chat.send does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.
  • -
  • Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like agent::work: from inheriting stale non-webchat routes.
  • -
  • Gateway/internal client routing continuity: prevent webchat/TUI/UI turns from inheriting stale external reply routes by requiring explicit deliver: true for external delivery, keeping main-session external inheritance scoped to non-Webchat/UI clients, and honoring configured session.mainKey when identifying main-session continuity. (from #35321, #34635, #35356) Thanks @alexyyyander and @Octane0411.
  • -
  • Security/auth labels: remove token and API-key snippets from user-facing auth status labels so /status and /models do not expose credential fragments. (#33262) thanks @cu1ch3n.
  • -
  • Models/MiniMax portal vision routing: add MiniMax-VL-01 to the minimax-portal provider, route portal image understanding through the MiniMax VLM endpoint, and align media auto-selection plus Telegram sticker description with the shared portal image provider path. (#33953) Thanks @tars90percent.
  • -
  • Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
  • -
  • Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown gateway.nodes.denyCommands entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
  • -
  • Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries.
  • -
  • Docs/security hardening guidance: document Docker DOCKER-USER + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
  • -
  • Docs/security threat-model links: replace relative .md links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.
  • -
  • Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.
  • -
  • iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
  • -
  • Gateway/chat.send command scopes: require operator.admin for persistent /config set|unset writes routed through gateway chat clients while keeping /config show available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.
  • -
  • iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
  • -
  • iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
  • -
  • Docs/tool-loop detection config keys: align docs/tools/loop-detection.md examples and field names with the current tools.loopDetection schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
  • -
  • Gateway/session agent discovery: include disk-scanned agent IDs in listConfiguredAgentIds even when agents.list is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.
  • -
  • Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
  • -
  • Discord/Agent-scoped media roots: pass mediaLocalRoots through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
  • -
  • Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
  • -
  • Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.
  • -
  • Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
  • -
  • Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
  • -
  • ACP/sandbox spawn parity: block /acp spawn from sandboxed requester sessions with the same host-runtime guard already enforced for sessions_spawn({ runtime: "acp" }), preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.
  • -
  • Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.
  • -
  • Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
  • -
  • Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.
  • -
  • Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.
  • -
  • HEIC image inputs: accept HEIC/HEIF input_image sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc.
  • -
  • Gateway/HEIC input follow-up: keep non-HEIC input_image MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions maxTotalImageBytes against post-normalization image payload size. Thanks @vincentkoc.
  • -
  • Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.
  • -
  • Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
  • -
  • Telegram/DM draft final delivery: materialize text-only sendMessageDraft previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.
  • -
  • Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.
  • -
  • Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress NO_REPLY lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
  • -
  • Telegram/native commands commands.allowFrom precedence: make native Telegram commands honor commands.allowFrom as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc.
  • -
  • Telegram/groupAllowFrom sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.
  • -
  • Telegram/native group command auth: authorize native commands in groups and forum topics against groupAllowFrom and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.
  • -
  • Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt.
  • -
  • Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
  • -
  • Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
  • -
  • Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
  • -
  • Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.
  • -
  • Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.
  • -
  • Telegram/device pairing notifications: auto-arm one-shot notify on /pair qr, auto-ping on new pairing requests, and add manual fallback via /pair approve latest if the ping does not arrive. (#33299) thanks @mbelinky.
  • -
  • Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
  • -
  • macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (wss://.ts.net) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
  • -
  • iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.
  • -
  • iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.
  • -
  • iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.
  • -
  • iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.
  • -
  • Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement sendText (without sendMedia) to remain outbound-capable, gracefully fall back to text delivery for media payloads when sendMedia is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.
  • -
  • Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add openclaw doctor warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
  • -
  • Telegram/plugin outbound hook parity: run message_sending + message_sent in Telegram reply delivery, include reply-path hook metadata (mediaUrls, threadId), and report message_sent.success=false when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • Gateway/OpenAI chat completions: parse active-turn image_url content parts (including parameterized data URIs and guarded URL sources), forward them as multimodal images, accept image-only user turns, enforce per-request image-part/byte budgets, default URL-based image fetches to disabled unless explicitly enabled by config, and redact image base64 data in cache-trace/provider payload diagnostics. (#17685) Thanks @vincentkoc
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.
  • -
  • ACP/sessions_spawn parent stream visibility: add streamTo: "parent" for runtime: "acp" to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (.acp-stream.jsonl, returned as streamLogPath when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.
  • -
  • Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, /context, and openclaw doctor; add agents.defaults.bootstrapPromptTruncationWarning (off|once|always, default once) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.
  • -
  • Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
  • -
  • Agents/Session startup date grounding: substitute YYYY-MM-DD placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for /new and /reset prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.
  • -
  • Agents/Compaction template heading alignment: update AGENTS template section names to Session Startup/Red Lines and keep legacy Every Session/Safety fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.
  • -
  • Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
  • -
  • Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.
  • -
  • Gateway/status self version reporting: make Gateway self version in openclaw status prefer runtime VERSION (while preserving explicit OPENCLAW_VERSION override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
  • -
  • Memory/QMD index isolation: set QMD_CONFIG_DIR alongside XDG_CONFIG_HOME so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
  • -
  • Memory/QMD collection safety: stop destructive collection rebinds when QMD collection list only reports names without path metadata, preventing memory search from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.
  • -
  • Memory/QMD duplicate-document recovery: detect UNIQUE constraint failed: documents.collection, documents.path update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.
  • -
  • Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed embedQuery + embedBatch concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.
  • -
  • LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
  • -
  • LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
  • -
  • LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
  • -
  • LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
  • -
  • LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
  • -
  • Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.
  • -
  • Feishu/groupPolicy legacy alias compatibility: treat legacy groupPolicy: "allowall" as open in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when groupAllowFrom is empty. (from #36358) Thanks @Sid-Qin.
  • -
  • Mattermost/plugin SDK import policy: replace remaining monolithic openclaw/plugin-sdk imports in Mattermost mention-gating paths/tests with scoped subpaths (openclaw/plugin-sdk/compat and openclaw/plugin-sdk/mattermost) so pnpm check passes lint:plugins:no-monolithic-plugin-sdk-entry-imports on baseline. (#36480) Thanks @Takhoffman.
  • -
  • Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (sendMessage + poll). (#36547) thanks @gumadeiras.
  • -
  • Agents/failover cooldown classification: stop treating generic cooling down text as provider rate_limit so healthy models no longer show false global cooldown/rate-limit warnings while explicit model_cooldown markers still trigger failover. (#32972) thanks @stakeswky.
  • -
  • Agents/failover service-unavailable handling: stop treating bare proxy/CDN service unavailable errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.
  • -
  • Plugins/HTTP route migration diagnostics: rewrite legacy api.registerHttpHandler(...) loader failures into actionable migration guidance so doctor/plugin diagnostics point operators to api.registerHttpRoute(...) or registerPluginHttpRoute(...). (#36794) Thanks @vincentkoc
  • -
  • Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit directPolicy so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc.
  • -
  • Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local Current time: lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff.
  • -
  • Ollama/local model handling: preserve explicit lower contextWindow / maxTokens overrides during merge refresh, and keep native Ollama streamed replies from surfacing fallback thinking / reasoning text once real content starts streaming. (#39292) Thanks @vincentkoc.
  • -
  • TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with operator.admin as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin.
  • -
  • Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob.
  • -
  • Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant.
  • -
  • Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily memory/YYYY-MM-DD.md file. (#34951) thanks @zerone0x.
  • -
  • Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin.
  • -
  • Agents/gateway config guidance: stop exposing config.schema through the agent gateway tool, remove prompt/docs guidance that told agents to call it, and keep agents on config.get plus config.patch/config.apply for config changes. (#7382) thanks @kakuteki.
  • -
  • Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy reasoning_effort when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc.
  • -
  • Agents/failover: classify periodic provider limit exhaustion text (for example Weekly/Monthly Limit Exhausted) as rate_limit while keeping explicit 402 Payment Required variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt.
  • -
  • Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm.
  • -
  • Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc.
  • -
  • Telegram/Discord media upload caps: make outbound uploads honor channel mediaMaxMb config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc.
  • -
  • Skills/nano-banana-pro resolution override: respect explicit --resolution values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen CLI validation: validate --background and --style inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen output formats: validate --output-format values early, normalize aliases like jpg -> jpeg, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc.
  • -
  • ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc.
  • -
  • WhatsApp media upload caps: make outbound media sends and auto-replies honor channels.whatsapp.mediaMaxMb with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.
  • -
  • Windows/Plugin install: when OpenClaw runs on Windows via Bun and npm-cli.js is not colocated with the runtime binary, fall back to npm.cmd/npx.cmd through the existing cmd.exe wrapper so openclaw plugins install no longer fails with spawn EINVAL. (#38056) Thanks @0xlin2023.
  • -
  • Telegram/send retry classification: retry grammY Network request ... failed after N attempts envelopes in send flows without reclassifying plain Network request ... failed! wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.
  • -
  • Gateway/probes: keep /health, /healthz, /ready, and /readyz reachable when the Control UI is mounted at /, preserve plugin-owned route precedence on those paths, and make /ready and /readyz report channel-backed readiness with startup grace plus 503 on disconnected managed channels, while /health and /healthz stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc.
  • -
  • Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level httpTimeoutMs applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow.
  • -
  • PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei.
  • -
  • Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so openclaw agent --json no longer crashes when provider payloads omit totalTokens or related usage fields. (#34977) thanks @sp-hk2ldn.
  • -
  • Venice/default model refresh: switch the built-in Venice default to kimi-k2-5, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc.
  • -
  • Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect 429/Retry-After. Thanks @vincentkoc.
  • -
  • Google Chat/multi-account webhook auth fallback: when channels.googlechat.accounts.default carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.
  • -
  • Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.
  • -
  • Gateway/transient network classification: treat wrapped ...: fetch failed transport messages as transient while avoiding broad matches like Web fetch failed (404): ..., preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.
  • -
  • ACP/console silent reply suppression: filter ACP NO_REPLY lead fragments and silent-only finals before openclaw agent logging/delivery so console-backed ACP sessions no longer leak NO/NO_REPLY placeholders. (#38436) Thanks @ql-wade.
  • -
  • Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.
  • -
  • Agents/reply MEDIA delivery: normalize local assistant MEDIA: paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.
  • -
  • Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing sessionKey rolls to a new sessionId across auto-reply, command, and isolated cron session resolvers, so AGENTS.md/MEMORY.md/USER.md updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.
  • -
  • Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.
  • -
  • Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running systemctl --user is-enabled, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.
  • -
  • Gateway/container lifecycle: allow openclaw gateway stop to SIGTERM unmanaged gateway listeners and openclaw gateway restart to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by service disabled no-op responses. Fixes #36137. Thanks @vincentkoc.
  • -
  • Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.
  • -
  • Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic agentId overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.
  • -
  • Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline data:image/... markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.
  • -
  • Config/compaction safeguard settings: regression-test agents.defaults.compaction.recentTurnsPreserve through loadConfig() and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.
  • -
  • iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.
  • -
  • CLI/Docs memory help accuracy: clarify openclaw memory status --deep behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.
  • -
  • Auto-reply/allowlist store account scoping: keep /allowlist ... --store writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.
  • -
  • Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (x-forwarded-for / x-real-ip) and rejecting sec-fetch-site: cross-site; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.
  • -
  • CLI/bootstrap Node version hint maintenance: replace hardcoded nvm 22 instructions in openclaw.mjs with MIN_NODE_MAJOR interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.
  • -
  • Discord/native slash command auth: honor commands.allowFrom.discord (and commands.allowFrom["*"]) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.
  • -
  • Outbound/message target normalization: ignore empty legacy to/channelId fields when explicit target is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo.
  • -
  • Models/auth token prompts: guard cancelled manual token prompts so Symbol(clack:cancel) values cannot be persisted into auth profiles; adds regression coverage for cancelled models auth paste-token. (#38951) Thanks @MumuTW.
  • -
  • Gateway/loopback announce URLs: treat http:// and https:// aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo.
  • -
  • Models/default provider fallback: when the hardcoded default provider is removed from models.providers, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV.
  • -
  • Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with Maximum call stack size exceeded; adds regression coverage. (#38935) Thanks @MumuTW.
  • -
  • Extensions/diffs CI stability: add headers to the localReq test helper in extensions/diffs/index.test.ts so forwarding-hint checks no longer crash with req.headers undefined. (supersedes #39063) Thanks @Shennng.
  • -
  • Agents/compaction thresholding: apply agents.defaults.contextTokens cap to the model passed into embedded run and /compact session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.
  • -
  • Models/merge mode provider precedence: when models.mode: "merge" is active and config explicitly sets a provider baseUrl, keep config as source of truth instead of preserving stale runtime models.json baseUrl values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.
  • -
  • UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling tool-events capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.
  • -
  • Models/provider apiKey persistence hardening: when a provider apiKey value equals a known provider env var value, persist the canonical env var name into models.json instead of resolved plaintext secrets. (#38889) Thanks @gambletan.
  • -
  • Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp.
  • -
  • Agents/OpenAI WS compat store flag: omit store from response.create payloads when model compat sets supportsStore: false, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob.
  • -
  • Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888.
  • -
  • Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (willRetry=true) in the compaction counter while still excluding aborted/no-result events, so /status reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW.
  • -
  • Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool start events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping.
  • -
  • Voice-call plugin schema parity: add missing manifest configSchema fields (webhookSecurity, streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections, staleCallReaperSeconds) so gateway AJV validation accepts already-supported runtime config instead of failing with additionalProperties errors. (#38892) Thanks @giumex.
  • -
  • Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both error and close, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob.
  • -
  • Daemon/Windows schtasks runtime detection: use locale-invariant Last Run Result running codes (0x41301/267009) as the primary running signal so openclaw node status no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk.
  • -
  • Usage/token count formatting: round near-million token counts to millions (1.0m) instead of 1000k, with explicit boundary coverage for 999_499 and 999_500. (#39129) Thanks @CurryMessi.
  • -
  • Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between /new/sessions.reset turns. (#38873) Thanks @MumuTW.
  • -
  • Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading "Can't reach service" wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl.
  • -
  • Telegram/polling offset safety: confirm persisted offsets before polling startup while validating stored lastUpdateId values as non-negative safe integers (with overflow guards) so malformed offset state cannot cause update skipping/dropping. (#39111) Thanks @MumuTW.
  • -
  • Telegram/status SecretRef read-only resolution: resolve env-backed bot-token SecretRefs in config-only/status inspection while respecting provider source/defaults and env allowlists, so status no longer crashes or reports false-ready tokens for disallowed providers. (#39130) Thanks @neocody.
  • -
  • Agents/OpenAI WS max-token zero forwarding: treat maxTokens: 0 as an explicit value in websocket response.create payloads (instead of dropping it as falsy), with regression coverage for zero-token forwarding. (#39148) Thanks @scoootscooob.
  • -
  • Podman/.env gateway bind precedence: evaluate OPENCLAW_GATEWAY_BIND after sourcing .env in run-openclaw-podman.sh so env-file overrides are honored. (#38785) Thanks @majinyu666.
  • -
  • Models/default alias refresh: bump gpt to openai/gpt-5.4 and Gemini defaults to gemini-3.1 preview aliases (including normalization/default wiring) to track current model IDs. (#38638) Thanks @ademczuk.
  • -
  • Config/env substitution degraded mode: convert missing ${VAR} resolution in config reads from hard-fail to warning-backed degraded behavior, while preventing unresolved placeholders from being accepted as gateway credentials. (#39050) Thanks @akz142857.
  • -
  • Discord inbound listener non-blocking dispatch: make MESSAGE_CREATE listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.
  • -
  • Daemon/Windows PATH freeze fix: stop persisting install-time PATH snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.
  • -
  • Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.
  • -
  • Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses /talkvoice natively on Discord while keeping text /voice.
  • -
  • Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric Last Run Result codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob.
  • -
  • Telegram/polling conflict recovery: reset the polling webhookCleared latch on getUpdates 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell.
  • -
  • Heartbeat/requests-in-flight scheduling: stop advancing nextDueMs and avoid immediate scheduleNext() timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW.
  • -
  • Memory/SQLite contention resilience: re-apply PRAGMA busy_timeout on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate SQLITE_BUSY failures under lock contention. (#39183) Thanks @MumuTW.
  • -
  • Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber.
  • -
  • Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit.
  • -
  • Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in gateway.auth.password and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk.
  • -
  • Agents/OpenAI-responses compatibility: strip unsupported store payload fields when supportsStore=false (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.
  • -
  • Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.
  • -
  • Outbound delivery replay safety: use two-phase delivery ACK markers (.json -> .delivered -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.
  • -
  • Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.
  • -
  • Nodes/system.run PowerShell wrapper parsing: treat pwsh/powershell -EncodedCommand forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.
  • -
  • Control UI/auth error reporting: map generic browser Fetch failed websocket close errors back to actionable gateway auth messages (gateway token mismatch, authentication failed, retry later) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.
  • -
  • Media/mime unknown-kind handling: return undefined (not "unknown") for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.
  • -
  • Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so #-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.
  • -
  • Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through MediaPaths/MediaUrls/MediaTypes (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.
  • -
  • Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so env wrapper stacks cannot reach /bin/sh -c execution without the expected approval gate. Thanks @tdjackey for reporting.
  • -
  • Docker/token persistence on reconfigure: reuse the existing .env gateway token during docker-setup.sh reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.
  • -
  • Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via openai-completions) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.
  • -
  • Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with gateway token mismatch. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation.
  • -
  • Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (AGENTS.md, SOUL.md, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6.
  • -
  • Exec approvals/gateway-node policy: honor explicit ask=off from exec-approvals.json even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego.
  • -
  • Exec approvals/config fallback: inherit ask from exec-approvals.json when tools.exec.ask is unset, so local full/off defaults no longer fall back to on-miss for exec tool and nodes run. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9.
  • -
  • Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like bash scripts/foo.sh while still blocking -c/-s wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii.
  • -
  • Queue/followup dedupe across drain restarts: dedupe queued redelivery message_id values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena.
  • -
  • Telegram/preview-final edit idempotence: treat message is not modified errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.
  • -
  • Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.
  • -
  • Telegram/DM draft streaming restoration: restore native sendMessageDraft preview transport for DM answer streaming while keeping reasoning on message transport, with regression coverage to keep draft finalization from sending duplicate finals. (#39398) Thanks @obviyus.
  • -
  • Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.
  • -
  • ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot mode: "run" ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.
  • -
  • Discord/DM session-key normalization: rewrite legacy discord:dm:* and phantom direct-message discord:channel: session keys to discord:direct:* when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
  • -
  • Discord/native slash session fallback: treat empty configured bound-session keys as missing so /status and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
  • -
  • Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across toolCall, toolUse, and functionCall blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with Tool not found. (#39328) Thanks @vincentkoc.
  • -
  • Agents/parallel tool-call compatibility: honor parallel_tool_calls / parallelToolCalls extra params only for openai-completions and openai-responses payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.
  • -
  • Config/invalid-load fail-closed: stop converting INVALID_CONFIG into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
  • -
  • Agents/codex-cli sandbox defaults: switch the built-in Codex backend from read-only to workspace-write so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.
  • -
  • Gateway/health-monitor restart reason labeling: report disconnected instead of stuck for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.
  • -
  • Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
  • -
  • Gateway/Telegram webhook-mode recovery: add webhookCertPath to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
  • -
  • Discord/config schema parity: add channels.discord.agentComponents to the strict Zod config schema so valid agentComponents.enabled settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
  • -
  • ACPX/MCP session bootstrap: inject configured MCP servers into ACP session/new and session/load for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
  • -
  • Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of You. (#39414) Thanks @obviyus.
  • -
-

View full changelog

-]]>
- -
\ No newline at end of file diff --git a/apps/android/README.md b/apps/android/README.md index 0a92e4c8ec5..9c6baf807c9 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -30,8 +30,12 @@ cd apps/android ./gradlew :app:assembleDebug ./gradlew :app:installDebug ./gradlew :app:testDebugUnitTest +cd ../.. +bun run android:bundle:release ``` +`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`. + ## Kotlin Lint + Format ```bash diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b187e131048..46afccbc3bf 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -1,5 +1,7 @@ import com.android.build.api.variant.impl.VariantOutputImpl +val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider" + val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() } val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() } val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() } @@ -63,8 +65,8 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 202603130 - versionName = "2026.3.13" + versionCode = 2026031400 + versionName = "2026.3.14" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -78,6 +80,9 @@ android { } isMinifyEnabled = true isShrinkResources = true + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } debug { @@ -104,6 +109,10 @@ android { "/META-INF/LICENSE*.txt", "DebugProbesKt.bin", "kotlin-tooling-metadata.json", + "org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties", + "org/bouncycastle/x509/CertPathReviewerMessages*.properties", ) } } @@ -168,7 +177,6 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -193,7 +201,6 @@ dependencies { implementation("androidx.camera:camera-camera2:1.5.2") implementation("androidx.camera:camera-lifecycle:1.5.2") implementation("androidx.camera:camera-video:1.5.2") - implementation("androidx.camera:camera-view:1.5.2") implementation("com.google.android.gms:play-services-code-scanner:16.1.0") // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. @@ -211,3 +218,45 @@ dependencies { tasks.withType().configureEach { useJUnitPlatform() } + +val stripReleaseDnsjavaServiceDescriptor = + tasks.register("stripReleaseDnsjavaServiceDescriptor") { + val mergedJar = + layout.buildDirectory.file( + "intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar", + ) + + inputs.file(mergedJar) + outputs.file(mergedJar) + + doLast { + val jarFile = mergedJar.get().asFile + if (!jarFile.exists()) { + return@doLast + } + + val unpackDir = temporaryDir.resolve("merged-java-res") + delete(unpackDir) + copy { + from(zipTree(jarFile)) + into(unpackDir) + exclude(dnsjavaInetAddressResolverService) + } + delete(jarFile) + ant.invokeMethod( + "zip", + mapOf( + "destfile" to jarFile.absolutePath, + "basedir" to unpackDir.absolutePath, + ), + ) + } + } + +tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach { + dependsOn("mergeReleaseJavaResource") +} + +tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach { + dependsOn(stripReleaseDnsjavaServiceDescriptor) +} diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro index 78e4a363919..7c04b96833a 100644 --- a/apps/android/app/proguard-rules.pro +++ b/apps/android/app/proguard-rules.pro @@ -1,26 +1,6 @@ -# ── App classes ─────────────────────────────────────────────────── --keep class ai.openclaw.app.** { *; } - -# ── Bouncy Castle ───────────────────────────────────────────────── --keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** - -# ── CameraX ─────────────────────────────────────────────────────── --keep class androidx.camera.** { *; } - -# ── kotlinx.serialization ──────────────────────────────────────── --keep class kotlinx.serialization.** { *; } --keepclassmembers class * { - @kotlinx.serialization.Serializable *; -} --keepattributes *Annotation*, InnerClasses - -# ── OkHttp ──────────────────────────────────────────────────────── -dontwarn okhttp3.** -dontwarn okio.** --keep class okhttp3.internal.platform.** { *; } - -# ── Misc suppressions ──────────────────────────────────────────── -dontwarn com.sun.jna.** -dontwarn javax.naming.** -dontwarn lombok.Generated diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 128527144ef..80f42e02843 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -176,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.requestCanvasRehydrate(source = source, force = true) } + fun refreshHomeCanvasOverviewIfConnected() { + runtime.refreshHomeCanvasOverviewIfConnected() + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index bd94edef93c..dcf1e3bee89 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -210,7 +212,8 @@ class NodeRuntime(context: Context) { private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _isForeground.asStateFlow() - private var lastAutoA2uiUrl: String? = null + private var gatewayDefaultAgentId: String? = null + private var gatewayAgents: List = emptyList() private var didAutoRequestCanvasRehydrate = false private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false @@ -232,7 +235,7 @@ class NodeRuntime(context: Context) { updateStatus() micCapture.onGatewayConnectionChanged(true) scope.launch { - refreshBrandingFromGateway() + refreshHomeCanvasOverviewIfConnected() if (voiceReplySpeakerLazy.isInitialized()) { voiceReplySpeaker.refreshConfig() } @@ -270,7 +273,7 @@ class NodeRuntime(context: Context) { _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null updateStatus() - maybeNavigateToA2uiOnConnect() + showLocalCanvasOnConnect() }, onDisconnected = { message -> _nodeConnected.value = false @@ -396,6 +399,7 @@ class NodeRuntime(context: Context) { _mainSessionKey.value = trimmed talkMode.setMainSessionKey(trimmed) chat.applyMainSessionKey(trimmed) + updateHomeCanvasState() } private fun updateStatus() { @@ -415,6 +419,7 @@ class NodeRuntime(context: Context) { operator.isNotBlank() && operator != "Offline" -> operator else -> node } + updateHomeCanvasState() } private fun resolveMainSessionKey(): String { @@ -422,23 +427,31 @@ class NodeRuntime(context: Context) { return if (trimmed.isEmpty()) "main" else trimmed } - private fun maybeNavigateToA2uiOnConnect() { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return - val current = canvas.currentUrl()?.trim().orEmpty() - if (current.isEmpty() || current == lastAutoA2uiUrl) { - lastAutoA2uiUrl = a2uiUrl - canvas.navigate(a2uiUrl) - } - } - - private fun showLocalCanvasOnDisconnect() { - lastAutoA2uiUrl = null + private fun showLocalCanvasOnConnect() { _canvasA2uiHydrated.value = false _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null canvas.navigate("") } + private fun showLocalCanvasOnDisconnect() { + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + canvas.navigate("") + } + + fun refreshHomeCanvasOverviewIfConnected() { + if (!operatorConnected) { + updateHomeCanvasState() + return + } + scope.launch { + refreshBrandingFromGateway() + refreshAgentsFromGateway() + } + } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { scope.launch { if (!_nodeConnected.value) { @@ -602,6 +615,8 @@ class NodeRuntime(context: Context) { canvas.setDebugStatus(status, server ?: remote) } } + + updateHomeCanvasState() } fun setForeground(value: Boolean) { @@ -928,11 +943,177 @@ class NodeRuntime(context: Context) { val parsed = parseHexColorArgb(raw) _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + updateHomeCanvasState() } catch (_: Throwable) { // ignore } } + private suspend fun refreshAgentsFromGateway() { + if (!operatorConnected) return + try { + val res = operatorSession.request("agents.list", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return + val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty() + val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull()) + val agents = + (root["agents"] as? JsonArray)?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + val name = obj["name"].asStringOrNull()?.trim() + val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim() + GatewayAgentSummary( + id = id, + name = name?.takeIf { it.isNotEmpty() }, + emoji = emoji?.takeIf { it.isNotEmpty() }, + ) + } ?: emptyList() + + gatewayDefaultAgentId = defaultAgentId.ifEmpty { null } + gatewayAgents = agents + applyMainSessionKey(mainKey) + updateHomeCanvasState() + } catch (_: Throwable) { + // ignore + } + } + + private fun updateHomeCanvasState() { + val payload = + try { + json.encodeToString(makeHomeCanvasPayload()) + } catch (_: Throwable) { + null + } + canvas.updateHomeCanvasState(payload) + } + + private fun makeHomeCanvasPayload(): HomeCanvasPayload { + val state = resolveHomeCanvasGatewayState() + val gatewayName = normalized(_serverName.value) + val gatewayAddress = normalized(_remoteAddress.value) + val gatewayLabel = gatewayName ?: gatewayAddress ?: "Gateway" + val activeAgentId = resolveActiveAgentId() + val agents = homeCanvasAgents(activeAgentId) + + return when (state) { + HomeCanvasGatewayState.Connected -> + HomeCanvasPayload( + gatewayState = "connected", + eyebrow = "Connected to $gatewayLabel", + title = "Your agents are ready", + subtitle = + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC", + activeAgentCaption = "Selected on this phone", + agentCount = agents.size, + agents = agents.take(6), + footer = "The overview refreshes on reconnect and when this screen opens.", + ) + HomeCanvasGatewayState.Connecting -> + HomeCanvasPayload( + gatewayState = "connecting", + eyebrow = "Reconnecting", + title = "OpenClaw is syncing back up", + subtitle = + "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = "OC", + activeAgentCaption = "Gateway session in progress", + agentCount = agents.size, + agents = agents.take(4), + footer = "If the gateway is reachable, reconnect should complete without intervention.", + ) + HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline -> + HomeCanvasPayload( + gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline", + eyebrow = "Welcome to OpenClaw", + title = "Your phone stays quiet until it is needed", + subtitle = + "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel = gatewayLabel, + activeAgentName = "Main", + activeAgentBadge = "OC", + activeAgentCaption = "Connect to load your agents", + agentCount = agents.size, + agents = agents.take(4), + footer = "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", + ) + } + } + + private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState { + val lower = _statusText.value.trim().lowercase() + return when { + _isConnected.value -> HomeCanvasGatewayState.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting + lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error + else -> HomeCanvasGatewayState.Offline + } + } + + private fun resolveActiveAgentId(): String { + val mainKey = _mainSessionKey.value.trim() + if (mainKey.startsWith("agent:")) { + val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim() + if (agentId.isNotEmpty()) return agentId + } + return gatewayDefaultAgentId?.trim().orEmpty() + } + + private fun resolveActiveAgentName(activeAgentId: String): String { + if (activeAgentId.isNotEmpty()) { + gatewayAgents.firstOrNull { it.id == activeAgentId }?.let { agent -> + return normalized(agent.name) ?: agent.id + } + return activeAgentId + } + return gatewayAgents.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: "Main" + } + + private fun homeCanvasAgents(activeAgentId: String): List { + val defaultAgentId = gatewayDefaultAgentId?.trim().orEmpty() + return gatewayAgents + .map { agent -> + val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId + val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId + HomeCanvasAgentCard( + id = agent.id, + name = normalized(agent.name) ?: agent.id, + badge = homeCanvasBadge(agent), + caption = + when { + isActive -> "Active on this phone" + isDefault -> "Default agent" + else -> "Ready" + }, + isActive = isActive, + ) + }.sortedWith(compareByDescending { it.isActive }.thenBy { it.name.lowercase() }) + } + + private fun homeCanvasBadge(agent: GatewayAgentSummary): String { + val emoji = normalized(agent.emoji) + if (emoji != null) return emoji + val initials = + (normalized(agent.name) ?: agent.id) + .split(' ', '-', '_') + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + return if (initials.isNotEmpty()) initials else "OC" + } + + private fun normalized(value: String?): String? { + val trimmed = value?.trim().orEmpty() + return trimmed.ifEmpty { null } + } + private fun triggerCameraFlash() { // Token is used as a pulse trigger; value doesn't matter as long as it changes. _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() @@ -951,3 +1132,40 @@ class NodeRuntime(context: Context) { } } + +private enum class HomeCanvasGatewayState { + Connected, + Connecting, + Error, + Offline, +} + +private data class GatewayAgentSummary( + val id: String, + val name: String?, + val emoji: String?, +) + +@Serializable +private data class HomeCanvasPayload( + val gatewayState: String, + val eyebrow: String, + val title: String, + val subtitle: String, + val gatewayLabel: String, + val activeAgentName: String, + val activeAgentBadge: String, + val activeAgentCaption: String, + val agentCount: Int, + val agents: List, + val footer: String, +) + +@Serializable +private data class HomeCanvasAgentCard( + val id: String, + val name: String, + val badge: String, + val caption: String, + val isActive: Boolean, +) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt index 9efb2a924d7..0eab9d75a5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt @@ -34,6 +34,7 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + @Volatile private var homeCanvasStateJson: String? = null private val _currentUrl = MutableStateFlow(null) val currentUrl: StateFlow = _currentUrl.asStateFlow() @@ -56,6 +57,7 @@ class CanvasController { this.webView = webView reload() applyDebugStatus() + applyHomeCanvasState() } fun detach(webView: WebView) { @@ -88,6 +90,12 @@ class CanvasController { fun onPageFinished() { applyDebugStatus() + applyHomeCanvasState() + } + + fun updateHomeCanvasState(json: String?) { + homeCanvasStateJson = json + applyHomeCanvasState() } private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { @@ -142,6 +150,22 @@ class CanvasController { } } + private fun applyHomeCanvasState() { + val payload = homeCanvasStateJson ?: "null" + withWebViewOnMain { wv -> + val js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome($payload); + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + suspend fun eval(javaScript: String): String = withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 9ca5687e594..3416900ed5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -97,7 +97,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } - val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789 val displayUrl = "${if (tls) "https" else "http"}://$host:$port" return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index 0642f9b3a7e..c3a14fe5a54 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -134,43 +134,14 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) @Composable private fun ScreenTabScreen(viewModel: MainViewModel) { val isConnected by viewModel.isConnected.collectAsState() - val isNodeConnected by viewModel.isNodeConnected.collectAsState() - val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() - val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() - val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() - val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() - val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true - val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) - val restoreCtaText = - when { - canvasRehydratePending -> "Restore requested. Waiting for agent…" - !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! - else -> "Canvas reset. Tap to restore dashboard." + LaunchedEffect(isConnected) { + if (isConnected) { + viewModel.refreshHomeCanvasOverviewIfConnected() } + } Box(modifier = Modifier.fillMaxSize()) { CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - - if (showRestoreCta) { - Surface( - onClick = { - if (canvasRehydratePending) return@Surface - viewModel.requestCanvasRehydrate(source = "screen_tab_cta") - }, - modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), - shape = RoundedCornerShape(12.dp), - color = mobileSurface.copy(alpha = 0.9f), - border = BorderStroke(1.dp, mobileBorder), - shadowElevation = 4.dp, - ) { - Text( - text = restoreCtaText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - style = mobileCallout.copy(fontWeight = FontWeight.Medium), - color = mobileText, - ) - } - } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index a4eef3b9b09..5c24631cf0b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -92,6 +92,30 @@ class GatewayConfigResolverTest { assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) } + @Test + fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""") + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHost = "", + manualPort = "", + manualTls = true, + fallbackToken = "shared-token", + fallbackPassword = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) + assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) + } + private fun encodeSetupCode(payloadJson: String): String { return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) } diff --git a/apps/android/scripts/build-release-aab.ts b/apps/android/scripts/build-release-aab.ts new file mode 100644 index 00000000000..30e4bb0390b --- /dev/null +++ b/apps/android/scripts/build-release-aab.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const androidDir = join(scriptDir, ".."); +const buildGradlePath = join(androidDir, "app", "build.gradle.kts"); +const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab"); + +type VersionState = { + versionName: string; + versionCode: number; +}; + +type ParsedVersionMatches = { + versionNameMatch: RegExpMatchArray; + versionCodeMatch: RegExpMatchArray; +}; + +function formatVersionName(date: Date): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}.${month}.${day}`; +} + +function formatVersionCodePrefix(date: Date): string { + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + return `${year}${month}${day}`; +} + +function parseVersionMatches(buildGradleText: string): ParsedVersionMatches { + const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/); + const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/); + if (!versionCodeMatch || !versionNameMatch) { + throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`); + } + return { versionCodeMatch, versionNameMatch }; +} + +function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number { + const currentRaw = currentVersionCode.toString(); + let nextSuffix = 0; + + if (currentRaw.startsWith(todayPrefix)) { + const suffixRaw = currentRaw.slice(todayPrefix.length); + nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1; + } + + if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) { + throw new Error( + `Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`, + ); + } + + return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10); +} + +function resolveNextVersion(buildGradleText: string, date: Date): VersionState { + const { versionCodeMatch } = parseVersionMatches(buildGradleText); + const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10); + if (!Number.isInteger(currentVersionCode)) { + throw new Error(`Invalid Android versionCode in ${buildGradlePath}`); + } + + const versionName = formatVersionName(date); + const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date)); + return { versionName, versionCode }; +} + +function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string { + return buildGradleText + .replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`) + .replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`); +} + +async function sha256Hex(path: string): Promise { + const buffer = await Bun.file(path).arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function verifyBundleSignature(path: string): Promise { + await $`jarsigner -verify ${path}`.quiet(); +} + +async function main() { + const buildGradleFile = Bun.file(buildGradlePath); + const originalText = await buildGradleFile.text(); + const nextVersion = resolveNextVersion(originalText, new Date()); + const updatedText = updateBuildGradleVersions(originalText, nextVersion); + + if (updatedText === originalText) { + throw new Error("Android version bump produced no change"); + } + + console.log(`Android versionName -> ${nextVersion.versionName}`); + console.log(`Android versionCode -> ${nextVersion.versionCode}`); + + await Bun.write(buildGradlePath, updatedText); + + try { + await $`./gradlew :app:bundleRelease`.cwd(androidDir); + } catch (error) { + await Bun.write(buildGradlePath, originalText); + throw error; + } + + const bundleFile = Bun.file(bundlePath); + if (!(await bundleFile.exists())) { + throw new Error(`Signed bundle missing at ${bundlePath}`); + } + + await verifyBundleSignature(bundlePath); + const hash = await sha256Hex(bundlePath); + + console.log(`Signed AAB: ${bundlePath}`); + console.log(`SHA-256: ${hash}`); +} + +await main(); diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index db38e86df80..4297bc8ff57 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -1,8 +1,8 @@ // Shared iOS version defaults. // Generated overrides live in build/Version.xcconfig (git-ignored). -OPENCLAW_GATEWAY_VERSION = 0.0.0 -OPENCLAW_MARKETING_VERSION = 0.0.0 -OPENCLAW_BUILD_VERSION = 0 +OPENCLAW_GATEWAY_VERSION = 2026.3.14 +OPENCLAW_MARKETING_VERSION = 2026.3.14 +OPENCLAW_BUILD_VERSION = 202603140 #include? "../build/Version.xcconfig" diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 26b64ea7c65..41b98111b4e 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -16,7 +16,14 @@ extension CronJobEditor { self.agentId = job.agentId ?? "" self.enabled = job.enabled self.deleteAfterRun = job.deleteAfterRun ?? false - self.sessionTarget = job.sessionTarget + switch job.parsedSessionTarget { + case .predefined(let target): + self.sessionTarget = target + self.preservedSessionTargetRaw = nil + case .session(let id): + self.sessionTarget = .isolated + self.preservedSessionTargetRaw = "session:\(id)" + } self.wakeMode = job.wakeMode switch job.schedule { @@ -51,7 +58,7 @@ extension CronJobEditor { self.channel = trimmed.isEmpty ? "last" : trimmed self.to = delivery.to ?? "" self.bestEffortDeliver = delivery.bestEffort ?? false - } else if self.sessionTarget == .isolated { + } else if self.isIsolatedLikeSessionTarget { self.deliveryMode = .announce } } @@ -80,7 +87,7 @@ extension CronJobEditor { "name": name, "enabled": self.enabled, "schedule": schedule, - "sessionTarget": self.sessionTarget.rawValue, + "sessionTarget": self.effectiveSessionTargetRaw, "wakeMode": self.wakeMode.rawValue, "payload": payload, ] @@ -92,7 +99,7 @@ extension CronJobEditor { root["agentId"] = NSNull() } - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { root["delivery"] = self.buildDelivery() } @@ -160,7 +167,7 @@ extension CronJobEditor { } func buildSelectedPayload() throws -> [String: Any] { - if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } + if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() } switch self.payloadKind { case .systemEvent: let text = self.trimmed(self.systemEventText) @@ -171,7 +178,7 @@ extension CronJobEditor { } func validateSessionTarget(_ payload: [String: Any]) throws { - if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { + if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" { throw NSError( domain: "Cron", code: 0, @@ -181,7 +188,7 @@ extension CronJobEditor { ]) } - if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { + if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" { throw NSError( domain: "Cron", code: 0, @@ -257,6 +264,17 @@ extension CronJobEditor { return Int(floor(n * factor)) } + var effectiveSessionTargetRaw: String { + if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty { + return preserved + } + return self.sessionTarget.rawValue + } + + var isIsolatedLikeSessionTarget: Bool { + self.effectiveSessionTargetRaw != "main" + } + func formatDuration(ms: Int) -> String { DurationFormattingSupport.conciseDuration(ms: ms) } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift index a7d88a4f2fb..292f3a63284 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -16,7 +16,7 @@ struct CronJobEditor: View { + "Use an isolated session for agent turns so your main chat stays clean." static let sessionTargetNote = "Main jobs post a system event into the current main session. " - + "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." + + "Current and isolated-style jobs run agent turns and can announce results to a channel." static let scheduleKindNote = "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." static let isolatedPayloadNote = @@ -29,6 +29,7 @@ struct CronJobEditor: View { @State var agentId: String = "" @State var enabled: Bool = true @State var sessionTarget: CronSessionTarget = .main + @State var preservedSessionTargetRaw: String? @State var wakeMode: CronWakeMode = .now @State var deleteAfterRun: Bool = false @@ -117,6 +118,7 @@ struct CronJobEditor: View { Picker("", selection: self.$sessionTarget) { Text("main").tag(CronSessionTarget.main) Text("isolated").tag(CronSessionTarget.isolated) + Text("current").tag(CronSessionTarget.current) } .labelsHidden() .pickerStyle(.segmented) @@ -209,7 +211,7 @@ struct CronJobEditor: View { GroupBox("Payload") { VStack(alignment: .leading, spacing: 10) { - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { Text(Self.isolatedPayloadNote) .font(.footnote) .foregroundStyle(.secondary) @@ -289,8 +291,11 @@ struct CronJobEditor: View { self.sessionTarget = .isolated } } - .onChange(of: self.sessionTarget) { _, newValue in - if newValue == .isolated { + .onChange(of: self.sessionTarget) { oldValue, newValue in + if oldValue != newValue { + self.preservedSessionTargetRaw = nil + } + if newValue != .main { self.payloadKind = .agentTurn } else if newValue == .main, self.payloadKind == .agentTurn { self.payloadKind = .systemEvent diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index e0ce46c13da..40079453974 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -3,12 +3,39 @@ import Foundation enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { case main case isolated + case current var id: String { self.rawValue } } +enum CronCustomSessionTarget: Codable, Equatable { + case predefined(CronSessionTarget) + case session(id: String) + + var rawValue: String { + switch self { + case .predefined(let target): + return target.rawValue + case .session(let id): + return "session:\(id)" + } + } + + static func from(_ value: String) -> CronCustomSessionTarget { + if let predefined = CronSessionTarget(rawValue: value) { + return .predefined(predefined) + } + if value.hasPrefix("session:") { + let sessionId = String(value.dropFirst(8)) + return .session(id: sessionId) + } + // Fallback to isolated for unknown values + return .predefined(.isolated) + } +} + enum CronWakeMode: String, CaseIterable, Identifiable, Codable { case now case nextHeartbeat = "next-heartbeat" @@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable { let createdAtMs: Int let updatedAtMs: Int let schedule: CronSchedule - let sessionTarget: CronSessionTarget + private let sessionTargetRaw: String let wakeMode: CronWakeMode let payload: CronPayload let delivery: CronDelivery? let state: CronJobState + enum CodingKeys: String, CodingKey { + case id + case agentId + case name + case description + case enabled + case deleteAfterRun + case createdAtMs + case updatedAtMs + case schedule + case sessionTargetRaw = "sessionTarget" + case wakeMode + case payload + case delivery + case state + } + + /// Parsed session target (predefined or custom session ID) + var parsedSessionTarget: CronCustomSessionTarget { + CronCustomSessionTarget.from(self.sessionTargetRaw) + } + + /// Compatibility shim for existing editor/UI code paths that still use the + /// predefined enum. + var sessionTarget: CronSessionTarget { + switch self.parsedSessionTarget { + case .predefined(let target): + return target + case .session: + return .isolated + } + } + + var sessionTargetDisplayValue: String { + self.parsedSessionTarget.rawValue + } + + var transcriptSessionKey: String? { + switch self.parsedSessionTarget { + case .predefined(.main): + return nil + case .predefined(.isolated), .predefined(.current): + return "cron:\(self.id)" + case .session(let id): + return id + } + } + + var supportsAnnounceDelivery: Bool { + switch self.parsedSessionTarget { + case .predefined(.main): + return false + case .predefined(.isolated), .predefined(.current), .session: + return true + } + } + var displayName: String { let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled job" : trimmed diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 69655bdc302..85e45928853 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -18,7 +18,7 @@ extension CronSettings { } } HStack(spacing: 6) { - StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) + StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary) if let agentId = job.agentId, !agentId.isEmpty { StatusPill(text: "agent \(agentId)", tint: .secondary) @@ -34,9 +34,9 @@ extension CronSettings { @ViewBuilder func jobContextMenu(_ job: CronJob) -> some View { Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Open transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } } Divider() @@ -75,9 +75,9 @@ extension CronSettings { .labelsHidden() Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } .buttonStyle(.borderedProminent) - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } .buttonStyle(.bordered) } @@ -103,7 +103,7 @@ extension CronSettings { if let agentId = job.agentId, !agentId.isEmpty { LabeledContent("Agent") { Text(agentId) } } - LabeledContent("Session") { Text(job.sessionTarget.rawValue) } + LabeledContent("Session") { Text(job.sessionTargetDisplayValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Next run") { if let date = job.nextRunDate { @@ -224,7 +224,7 @@ extension CronSettings { HStack(spacing: 8) { if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if job.sessionTarget == .isolated { + if job.supportsAnnounceDelivery { let delivery = job.delivery if let delivery { if delivery.mode == .announce { diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 7fc4385b96c..141da33ad48 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -370,6 +370,17 @@ enum ExecApprovalsStore { static func resolve(agentId: String?) -> ExecApprovalsResolved { let file = self.ensureFile() + return self.resolveFromFile(file, agentId: agentId) + } + + /// Read-only resolve: loads file without writing (no ensureFile side effects). + /// Safe to call from background threads / off MainActor. + static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved { + let file = self.loadFile() + return self.resolveFromFile(file, agentId: agentId) + } + + private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved { let defaults = file.defaults ?? ExecApprovalsDefaults() let resolvedDefaults = ExecApprovalsResolvedDefaults( security: defaults.security ?? self.defaultSecurity, diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift index 379e8c0f559..08e60b84d2b 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift @@ -43,7 +43,33 @@ final class ExecApprovalsGatewayPrompter { do { let data = try JSONEncoder().encode(payload) let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) - guard self.shouldPresent(request: request) else { return } + let presentation = self.shouldPresent(request: request) + guard presentation.shouldAsk else { + // Ask policy says no prompt needed – resolve based on security policy + let decision: ExecApprovalDecision = presentation.security == .full ? .allowOnce : .deny + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + return + } + guard presentation.canPresent else { + let decision = Self.fallbackDecision( + request: request.request, + askFallback: presentation.askFallback, + allowlist: presentation.allowlist) + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + return + } let decision = ExecApprovalsPromptPresenter.prompt(request.request) try await GatewayConnection.shared.requestVoid( method: .execApprovalResolve, @@ -57,16 +83,89 @@ final class ExecApprovalsGatewayPrompter { } } - private func shouldPresent(request: GatewayApprovalRequest) -> Bool { + /// Whether the ask policy requires prompting the user. + /// Note: this only determines if a prompt is shown, not whether the action is allowed. + /// The security policy (full/deny/allowlist) decides the actual outcome. + private static func shouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool { + switch ask { + case .always: + return true + case .onMiss: + return security == .allowlist + case .off: + return false + } + } + + struct PresentationDecision { + /// Whether the ask policy requires prompting the user (not whether the action is allowed). + var shouldAsk: Bool + /// Whether the prompt can actually be shown (session match, recent activity, etc.). + var canPresent: Bool + /// The resolved security policy, used to determine allow/deny when no prompt is shown. + var security: ExecSecurity + /// Fallback security policy when a prompt is needed but can't be presented. + var askFallback: ExecSecurity + var allowlist: [ExecAllowlistEntry] + } + + private func shouldPresent(request: GatewayApprovalRequest) -> PresentationDecision { let mode = AppStateStore.shared.connectionMode let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - return Self.shouldPresent( + + // Read-only resolve to avoid disk writes on the MainActor + let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + + let shouldAsk = Self.shouldAsk(security: security, ask: ask) + + let canPresent = shouldAsk && Self.shouldPresent( mode: mode, activeSession: activeSession, requestSession: requestSession, lastInputSeconds: Self.lastInputSeconds(), thresholdSeconds: 120) + + return PresentationDecision( + shouldAsk: shouldAsk, + canPresent: canPresent, + security: security, + askFallback: approvals.agent.askFallback, + allowlist: approvals.allowlist) + } + + private static func fallbackDecision( + request: ExecApprovalPromptRequest, + askFallback: ExecSecurity, + allowlist: [ExecAllowlistEntry]) -> ExecApprovalDecision + { + guard askFallback == .allowlist else { + return askFallback == .full ? .allowOnce : .deny + } + let resolution = self.fallbackResolution(for: request) + let match = ExecAllowlistMatcher.match(entries: allowlist, resolution: resolution) + return match == nil ? .deny : .allowOnce + } + + private static func fallbackResolution(for request: ExecApprovalPromptRequest) -> ExecCommandResolution? { + let resolvedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolvedPath = (resolvedPath?.isEmpty == false) ? resolvedPath : nil + let rawExecutable = self.firstToken(from: request.command) ?? trimmedResolvedPath ?? "" + guard !rawExecutable.isEmpty || trimmedResolvedPath != nil else { return nil } + let executableName = trimmedResolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? rawExecutable + return ExecCommandResolution( + rawExecutable: rawExecutable, + resolvedPath: trimmedResolvedPath, + executableName: executableName, + cwd: request.cwd) + } + + private static func firstToken(from command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) } private static func shouldPresent( @@ -117,5 +216,29 @@ extension ExecApprovalsGatewayPrompter { lastInputSeconds: lastInputSeconds, thresholdSeconds: thresholdSeconds) } + + static func _testShouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool { + self.shouldAsk(security: security, ask: ask) + } + + static func _testFallbackDecision( + command: String, + resolvedPath: String?, + askFallback: ExecSecurity, + allowlistPatterns: [String]) -> ExecApprovalDecision + { + self.fallbackDecision( + request: ExecApprovalPromptRequest( + command: command, + cwd: nil, + host: nil, + security: nil, + ask: nil, + agentId: nil, + resolvedPath: resolvedPath, + sessionKey: nil), + askFallback: askFallback, + allowlist: allowlistPatterns.map { ExecAllowlistEntry(pattern: $0) }) + } } #endif diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index dfae5c3bcaa..7d8837415ff 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -47,7 +47,7 @@ actor PortGuardian { let listeners = await self.listeners(on: port) guard !listeners.isEmpty else { continue } for listener in listeners { - if self.isExpected(listener, port: port, mode: mode) { + if Self.isExpected(listener, port: port, mode: mode) { let message = """ port \(port) already served by expected \(listener.command) (pid \(listener.pid)) — keeping @@ -55,6 +55,14 @@ actor PortGuardian { self.logger.info("\(message, privacy: .public)") continue } + if mode == .remote { + let message = """ + port \(port) held by \(listener.command) + (pid \(listener.pid)) in remote mode — not killing + """ + self.logger.warning(message) + continue + } let killed = await self.kill(listener.pid) if killed { let message = """ @@ -271,8 +279,8 @@ actor PortGuardian { switch mode { case .remote: - expectedDesc = "SSH tunnel to remote gateway" - okPredicate = { $0.command.lowercased().contains("ssh") } + expectedDesc = "Remote gateway (SSH tunnel, Docker, or direct)" + okPredicate = { _ in true } case .local: expectedDesc = "Gateway websocket (node/tsx)" okPredicate = { listener in @@ -352,13 +360,12 @@ actor PortGuardian { return sigkill.ok } - private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { + private static func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { let cmd = listener.command.lowercased() let full = listener.fullCommand.lowercased() switch mode { case .remote: - // Remote mode expects an SSH tunnel for the gateway WebSocket port. - if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } + if port == GatewayEnvironment.gatewayPort() { return true } return false case .local: // The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc). @@ -406,6 +413,16 @@ extension PortGuardian { self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } } + static func _testIsExpected( + command: String, + fullCommand: String, + port: Int, + mode: AppState.ConnectionMode) -> Bool + { + let listener = Listener(pid: 0, command: command, fullCommand: fullCommand, user: nil) + return Self.isExpected(listener, port: port, mode: mode) + } + static func _testBuildReport( port: Int, mode: AppState.ConnectionMode, diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 218d638a7e5..89ebf70beb4 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.3.13 + 2026.3.14 CFBundleVersion - 202603130 + 202603140 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift index 3112f57879b..6f1ef2b723d 100644 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error { enum RuntimeLocator { private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0) static func resolve( searchPaths: [String] = CommandResolver.preferredPaths()) -> Result @@ -91,7 +91,7 @@ enum RuntimeLocator { switch error { case let .notFound(searchPaths): [ - "openclaw needs Node >=22.0.0 but found no runtime.", + "openclaw needs Node >=22.16.0 but found no runtime.", "PATH searched: \(searchPaths.joined(separator: ":"))", "Install Node: https://nodejs.org/en/download", ].joined(separator: "\n") @@ -105,7 +105,7 @@ enum RuntimeLocator { [ "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.0.0).", + "Try reinstalling or pinning a supported version (Node >=22.16.0).", ].joined(separator: "\n") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift index cd4e234ed66..03b17b42ab2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift @@ -52,4 +52,51 @@ struct ExecApprovalsGatewayPrompterTests { lastInputSeconds: 400) #expect(!remote) } + + // MARK: - shouldAsk + + @Test func askAlwaysPromptsRegardlessOfSecurity() { + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .always)) + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .always)) + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .always)) + } + + @Test func askOnMissPromptsOnlyForAllowlist() { + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .onMiss)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .onMiss)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .onMiss)) + } + + @Test func askOffNeverPrompts() { + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .off)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .off)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .off)) + } + + @Test func fallbackAllowlistAllowsMatchingResolvedPath() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .allowlist, + allowlistPatterns: ["/usr/bin/git"]) + #expect(decision == .allowOnce) + } + + @Test func fallbackAllowlistDeniesAllowlistMiss() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .allowlist, + allowlistPatterns: ["/usr/bin/rg"]) + #expect(decision == .deny) + } + + @Test func fallbackFullAllowsWhenPromptCannotBeShown() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .full, + allowlistPatterns: []) + #expect(decision == .allowOnce) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift index c8928978f74..a37135ff490 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -139,6 +139,54 @@ struct LowCoverageHelperTests { #expect(emptyReport.summary.contains("Nothing is listening")) } + @Test func `port guardian remote mode does not kill docker`() { + #expect(PortGuardian._testIsExpected( + command: "com.docker.backend", + fullCommand: "com.docker.backend", + port: 18789, mode: .remote) == true) + + #expect(PortGuardian._testIsExpected( + command: "ssh", + fullCommand: "ssh -L 18789:localhost:18789 user@host", + port: 18789, mode: .remote) == true) + + #expect(PortGuardian._testIsExpected( + command: "podman", + fullCommand: "podman", + port: 18789, mode: .remote) == true) + } + + @Test func `port guardian local mode still rejects unexpected`() { + #expect(PortGuardian._testIsExpected( + command: "com.docker.backend", + fullCommand: "com.docker.backend", + port: 18789, mode: .local) == false) + + #expect(PortGuardian._testIsExpected( + command: "python", + fullCommand: "python server.py", + port: 18789, mode: .local) == false) + + #expect(PortGuardian._testIsExpected( + command: "node", + fullCommand: "node /path/to/gateway-daemon", + port: 18789, mode: .local) == true) + } + + @Test func `port guardian remote mode report accepts any listener`() { + let dockerReport = PortGuardian._testBuildReport( + port: 18789, mode: .remote, + listeners: [(pid: 99, command: "com.docker.backend", + fullCommand: "com.docker.backend", user: "me")]) + #expect(dockerReport.offenders.isEmpty) + + let localDockerReport = PortGuardian._testBuildReport( + port: 18789, mode: .local, + listeners: [(pid: 99, command: "com.docker.backend", + fullCommand: "com.docker.backend", user: "me")]) + #expect(!localDockerReport.offenders.isEmpty) + } + @Test @MainActor func `canvas scheme handler resolves files and errors`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 990c033445f..782dbd77212 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -16,7 +16,7 @@ struct RuntimeLocatorTests { @Test func `resolve succeeds with valid node`() throws { let script = """ #!/bin/sh - echo v22.5.0 + echo v22.16.0 """ let node = try self.makeTempExecutable(contents: script) let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) @@ -25,7 +25,23 @@ struct RuntimeLocatorTests { return } #expect(res.path == node.path) - #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) + #expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0)) + } + + @Test func `resolve fails on boundary below minimum`() throws { + let script = """ + #!/bin/sh + echo v22.15.9 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.unsupported(_, found, required, path, _)) = result else { + Issue.record("Expected unsupported error, got \(result)") + return + } + #expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9)) + #expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0)) + #expect(path == node.path) } @Test func `resolve fails when too old`() throws { @@ -60,7 +76,17 @@ struct RuntimeLocatorTests { @Test func `describe failure includes paths`() { let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) + #expect(msg.contains("Node >=22.16.0")) #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) + + let parseMsg = RuntimeLocator.describeFailure( + .versionParse( + kind: .node, + raw: "garbage", + path: "/usr/local/bin/node", + searchPaths: ["/usr/local/bin"], + )) + #expect(parseMsg.contains("Node >=22.16.0")) } @Test func `runtime version parses with leading V and metadata`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift index eac7ceea37d..fcf3f3b1158 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift @@ -74,4 +74,22 @@ struct VoiceWakeRuntimeTests { let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing") } + + @Test func `gate command text handles foreign string ranges`() { + let transcript = "hey openclaw do thing" + let other = "do thing" + let foreignRange = other.range(of: "do") + let segments = [ + WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")), + WakeWordSegment(text: "openclaw", start: 0.2, duration: 0.1, range: transcript.range(of: "openclaw")), + WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange), + WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil), + ] + + #expect( + WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: 0.3) == "do thing") + } } diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index effa8f3ab81..cb27380416b 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). + - **Isolated**: run a dedicated agent turn in `cron:` or a custom session, with delivery (announce by default or none). + - **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`). + - **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. - Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. @@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do. 2. **Choose where it runs** - `sessionTarget: "main"` → run during the next heartbeat with main context. - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. + - `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:`). + - `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs. + + Default behavior (unchanged): + - `systemEvent` payloads default to `main` + - `agentTurn` payloads default to `isolated` + + To use current session binding, explicitly set `sessionTarget: "current"`. 3. **Choose the payload** - Main session → `payload.kind = "systemEvent"` @@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat). #### Isolated jobs (dedicated cron sessions) -Isolated jobs run a dedicated agent turn in session `cron:`. +Isolated jobs run a dedicated agent turn in session `cron:` or a custom session. Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. -- Each run starts a **fresh session id** (no prior conversation carry-over). +- Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session. +- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). - `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. @@ -321,12 +332,42 @@ Recurring, isolated job with delivery: } ``` +Recurring job bound to current session (auto-resolved at creation): + +```json +{ + "name": "Daily standup", + "schedule": { "kind": "cron", "expr": "0 9 * * *" }, + "sessionTarget": "current", + "payload": { + "kind": "agentTurn", + "message": "Summarize yesterday's progress." + } +} +``` + +Recurring job in a custom persistent session: + +```json +{ + "name": "Project monitor", + "schedule": { "kind": "every", "everyMs": 300000 }, + "sessionTarget": "session:project-alpha-monitor", + "payload": { + "kind": "agentTurn", + "message": "Check project status and update the running log." + } +} +``` + Notes: - `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. -- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. +- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:"`. +- `"current"` is resolved to `"session:"` at creation time. +- Custom sessions (`session:xxx`) maintain persistent context across runs. - Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 9676d960d23..09f9187c368 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -219,13 +219,13 @@ See [Lobster](/tools/lobster) for full usage and examples. Both heartbeat and cron can interact with the main session, but differently: -| | Heartbeat | Cron (main) | Cron (isolated) | -| ------- | ------------------------------- | ------------------------ | -------------------------- | -| Session | Main | Main (via system event) | `cron:` | -| History | Shared | Shared | Fresh each run | -| Context | Full | Full | None (starts clean) | -| Model | Main session model | Main session model | Can override | -| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | +| | Heartbeat | Cron (main) | Cron (isolated) | +| ------- | ------------------------------- | ------------------------ | ----------------------------------------------- | +| Session | Main | Main (via system event) | `cron:` or custom session | +| History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) | +| Context | Full | Full | None (isolated) / Cumulative (custom) | +| Model | Main session model | Main session model | Can override | +| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | ### When to use main session cron diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a0c679988d3..37be3bf1111 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -782,6 +782,11 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ - `--poll-public` - `--thread-id` for forum topics (or use a `:topic:` target) + Telegram send also supports: + + - `--buttons` for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it + - `--force-document` to send outbound images and GIFs as documents instead of compressed photo or animated-media uploads + Action gating: - `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 8e0ddad92ef..f9ddc151717 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -27,7 +27,7 @@ Related: ## Quick start (local) ```bash -openclaw browser --browser-profile chrome tabs +openclaw browser profiles openclaw browser --browser-profile openclaw start openclaw browser --browser-profile openclaw open https://example.com openclaw browser --browser-profile openclaw snapshot @@ -38,7 +38,8 @@ openclaw browser --browser-profile openclaw snapshot Profiles are named browser routing configs. In practice: - `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). -- `chrome`: controls your existing Chrome tab(s) via the Chrome extension relay. +- `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP. +- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay. ```bash openclaw browser profiles diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 95c20e3aa7c..16b05baefce 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -95,6 +95,7 @@ openclaw gateway health --url ws://127.0.0.1:18789 ```bash openclaw gateway status openclaw gateway status --json +openclaw gateway status --require-rpc ``` Options: @@ -105,11 +106,13 @@ Options: - `--timeout `: probe timeout (default `10000`). - `--no-probe`: skip the RPC probe (service-only view). - `--deep`: scan system-level services too. +- `--require-rpc`: exit non-zero when the RPC probe fails. Cannot be combined with `--no-probe`. Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. - If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy. - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). ### `gateway probe` @@ -126,6 +129,23 @@ openclaw gateway probe openclaw gateway probe --json ``` +Interpretation: + +- `Reachable: yes` means at least one target accepted a WebSocket connect. +- `RPC: ok` means detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. +- `RPC: limited - missing scope: operator.read` means connect succeeded but detail RPC is scope-limited. This is reported as **degraded** reachability, not full failure. +- Exit code is non-zero only when no probed target is reachable. + +JSON notes (`--json`): + +- Top level: + - `ok`: at least one target is reachable. + - `degraded`: at least one target had scope-limited detail RPC. +- Per target (`targets[].connect`): + - `ok`: reachability after connect + degraded classification. + - `rpcOk`: full detail RPC success. + - `scopeLimited`: detail RPC failed due to missing operator scope. + #### Remote over SSH (Mac app parity) The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. diff --git a/docs/cli/index.md b/docs/cli/index.md index 2796e7927d2..ddedc7ca1aa 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -780,7 +780,7 @@ Subcommands: Notes: - `gateway status` probes the Gateway RPC by default using the service’s resolved port/config (override with `--url/--token/--password`). -- `gateway status` supports `--no-probe`, `--deep`, and `--json` for scripting. +- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. - On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. diff --git a/docs/cli/message.md b/docs/cli/message.md index 195e884a01d..1633554f316 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -59,6 +59,7 @@ Name lookup: - Required: `--target`, plus `--message` or `--media` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) + - Telegram only: `--force-document` (send images and GIFs as documents to avoid Telegram compression) - Telegram only: `--thread-id` (forum topic id) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - WhatsApp only: `--gif-playback` @@ -258,3 +259,10 @@ Send Telegram inline buttons: openclaw message send --channel telegram --target @mychat --message "Choose:" \ --buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' ``` + +Send a Telegram image as a document to avoid compression: + +```bash +openclaw message send --channel telegram --target @mychat \ + --media ./diagram.png --force-document +``` diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 8ed755b394c..2649125dc45 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -23,6 +23,8 @@ The default workspace layout uses two memory layers: - Read today + yesterday at session start. - `MEMORY.md` (optional) - Curated long-term memory. + - If both `MEMORY.md` and `memory.md` exist at the workspace root, OpenClaw only loads `MEMORY.md`. + - Lowercase `memory.md` is only used as a fallback when `MEMORY.md` is absent. - **Only load in the main, private session** (never in group contexts). These files live under the workspace (`agents.defaults.workspace`, default diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 2a58c15cb4d..2f00325b730 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -200,7 +200,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Legacy `group:` keys are still recognized for migration. - Inbound contexts may still use `group:`; the channel is inferred from `Provider` and normalized to the canonical `agent:::group:` form. - Other sources: - - Cron jobs: `cron:` + - Cron jobs: `cron:` (isolated) or custom `session:` (persistent) - Webhooks: `hook:` (unless explicitly set by the hook) - Node runs: `node-` diff --git a/docs/docs.json b/docs/docs.json index 402d56aa380..07a88de39f7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1009,7 +1009,8 @@ "tools/loop-detection", "tools/reactions", "tools/thinking", - "tools/web" + "tools/web", + "tools/btw" ] }, { diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b4a697d5a5a..658a3084437 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2342,7 +2342,7 @@ See [Plugins](/tools/plugin). browser: { enabled: true, evaluateEnabled: true, - defaultProfile: "chrome", + defaultProfile: "user", ssrfPolicy: { dangerouslyAllowPrivateNetwork: true, // default trusted-network mode // allowPrivateNetwork: true, // legacy alias diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ebea28a6541..f5829454e57 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -289,7 +289,7 @@ Look for: - Valid browser executable path. - CDP profile reachability. -- Extension relay tab attachment for `profile="chrome"`. +- Extension relay tab attachment for `profile="chrome-relay"`. Common signatures: diff --git a/docs/help/faq.md b/docs/help/faq.md index 37f5f96c815..236097634c1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1358,7 +1358,8 @@ Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and confi These files live in the **agent workspace**, not `~/.openclaw`. - **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, - `MEMORY.md` (or `memory.md`), `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. + `MEMORY.md` (or legacy fallback `memory.md` when `MEMORY.md` is absent), + `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. - **State dir (`~/.openclaw`)**: config, credentials, auth profiles, sessions, logs, and shared skills (`~/.openclaw/skills`). diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 951e1a480d7..a3988c4ea58 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -28,7 +28,7 @@ Good output in one line: - `openclaw status` → shows configured channels and no obvious auth errors. - `openclaw status --all` → full report is present and shareable. -- `openclaw gateway probe` → expected gateway target is reachable. +- `openclaw gateway probe` → expected gateway target is reachable (`Reachable: yes`). `RPC: limited - missing scope: operator.read` is degraded diagnostics, not a connect failure. - `openclaw gateway status` → `Runtime: running` and `RPC probe: ok`. - `openclaw doctor` → no blocking config/service errors. - `openclaw channels status --probe` → channels report `connected` or `ready`. diff --git a/docs/providers/glm.md b/docs/providers/glm.md index f65ea81f9da..64fe39a42df 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -14,7 +14,17 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 93313acba3f..6f3aea27020 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -15,9 +15,17 @@ with a Z.AI API key. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key -# or non-interactive -openclaw onboard --zai-api-key "$ZAI_API_KEY" +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 6e2869403f5..7427f53c071 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -48,7 +48,8 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md ## Session start (required) -- Read `SOUL.md`, `USER.md`, `memory.md`, and today+yesterday in `memory/`. +- Read `SOUL.md`, `USER.md`, and today+yesterday in `memory/`. +- Read `MEMORY.md` when present; only fall back to lowercase `memory.md` when `MEMORY.md` is absent. - Do it before responding. ## Soul (required) @@ -65,8 +66,9 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md ## Memory system (recommended) - Daily log: `memory/YYYY-MM-DD.md` (create `memory/` if needed). -- Long-term memory: `memory.md` for durable facts, preferences, and decisions. -- On session start, read today + yesterday + `memory.md` if present. +- Long-term memory: `MEMORY.md` for durable facts, preferences, and decisions. +- Lowercase `memory.md` is legacy fallback only; do not keep both root files on purpose. +- On session start, read today + yesterday + `MEMORY.md` when present, otherwise `memory.md`. - Capture: decisions, preferences, constraints, open loops. - Avoid secrets unless explicitly requested. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index f929d16e5f7..9100968550a 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning. - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` +- Fallback correction tag: `vYYYY.M.D-N` + - Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it. + - The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release. + - Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready. - Use the same version string everywhere, minus the leading `v` where Git tags are not used: - `package.json`: `2026.3.8` - Git tag: `v2026.3.8` @@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning. - `latest` = stable - `beta` = prerelease/testing - Dev is the moving head of `main`, not a normal git-tagged release. -- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. Historical note: - Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. -- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. +- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. 1. **Version & metadata** @@ -94,10 +98,14 @@ Historical note: - [ ] Confirm git status is clean; commit and push as needed. - [ ] Confirm npm trusted publishing is configured for the `openclaw` package. -- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`. +- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing. +- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`. +- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - Stable tags publish to npm `latest`. - Beta tags publish to npm `beta`. - - The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`. + - Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) @@ -107,8 +115,9 @@ Historical note: - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` - **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache: - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match: - - `git tag -f vX.Y.Z && git push -f origin vX.Y.Z` +- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`. + - Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only. + - Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release. 7. **GitHub release + appcast** diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 01e6cbc3ff9..1ab51657044 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs ### Problem: "Chrome extension relay is running, but no tab is connected" -You’re using the `chrome` profile (extension relay). It expects the OpenClaw +You’re using the `chrome-relay` profile (extension relay). It expects the OpenClaw browser extension to be attached to a live tab. Fix options: @@ -135,5 +135,5 @@ Fix options: Notes: -- The `chrome` profile uses your **system default Chromium browser** when possible. +- The `chrome-relay` profile uses your **system default Chromium browser** when possible. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP. diff --git a/docs/tools/browser-login.md b/docs/tools/browser-login.md index 910c21ca218..d570b3b2e87 100644 --- a/docs/tools/browser-login.md +++ b/docs/tools/browser-login.md @@ -20,6 +20,13 @@ Back to the main browser docs: [Browser](/tools/browser). OpenClaw controls a **dedicated Chrome profile** (named `openclaw`, orange‑tinted UI). This is separate from your daily browser profile. +For agent browser tool calls: + +- Default choice: the agent should use its isolated `openclaw` browser. +- Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. +- If you have multiple user-browser profiles, specify the profile explicitly instead of guessing. + Two easy ways to access it: 1. **Ask the agent to open the browser** and then log in yourself. diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index d63bb891c48..2e7844860aa 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -33,7 +33,7 @@ Choose this when: ### Option 2: Chrome extension relay -Use the built-in `chrome` profile plus the OpenClaw Chrome extension. +Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension. Choose this when: @@ -155,7 +155,7 @@ Example: { browser: { enabled: true, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", relayBindHost: "0.0.0.0", }, } @@ -197,7 +197,7 @@ openclaw browser tabs --browser-profile remote For the extension relay: ```bash -openclaw browser tabs --browser-profile chrome +openclaw browser tabs --browser-profile chrome-relay ``` Good result: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 8a7abe93209..ebe352036c5 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -18,8 +18,8 @@ Beginner view: - Think of it as a **separate, agent-only browser**. - The `openclaw` profile does **not** touch your personal browser profile. - The agent can **open tabs, read pages, click, and type** in a safe lane. -- The default `chrome` profile uses the **system default Chromium browser** via the - extension relay; switch to `openclaw` for the isolated managed browser. +- The built-in `user` profile attaches to your real signed-in Chrome session; + `chrome-relay` is the explicit extension-relay profile. ## What you get @@ -43,13 +43,22 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. -## Profiles: `openclaw` vs `chrome` +## Profiles: `openclaw` vs `user` vs `chrome-relay` - `openclaw`: managed, isolated browser (no extension required). -- `chrome`: extension relay to your **system browser** (requires the OpenClaw - extension to be attached to a tab). -- `existing-session`: official Chrome MCP attach flow for a running Chrome - profile. +- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome** + session. +- `chrome-relay`: extension relay to your **system browser** (requires the + OpenClaw extension to be attached to a tab). + +For agent browser tool calls: + +- Default: use the isolated `openclaw` browser. +- Prefer `profile="user"` when existing logged-in sessions matter and the user + is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome + extension / toolbar-button attach flow. +- `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -70,7 +79,7 @@ Browser settings live in `~/.openclaw/openclaw.json`. // 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) - defaultProfile: "chrome", + defaultProfile: "openclaw", color: "#FF4500", headless: false, noSandbox: false, @@ -79,12 +88,16 @@ Browser settings live in `~/.openclaw/openclaw.json`. profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, - chromeLive: { - cdpPort: 18802, + user: { driver: "existing-session", attachOnly: true, color: "#00AA00", }, + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#00AA00", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -105,7 +118,7 @@ Notes: - `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 `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay. +- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay. - Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do @@ -279,7 +292,7 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be: Defaults: - The `openclaw` profile is auto-created if missing. -- The `chrome` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). +- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). - Existing-session profiles are opt-in; create them with `--driver existing-session`. - Local CDP ports allocate from **18800–18899** by default. - Deleting a profile moves its local data directory to Trash. @@ -323,8 +336,8 @@ openclaw browser extension install 2. Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `profile="chrome"` +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` Optional: if you want a different name or relay port, create your own profile: @@ -340,6 +353,9 @@ Notes: - This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). - Detach by clicking the extension icon again. +- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"` + only when you specifically want the extension flow. The user must be present + to click the extension and attach the tab. ## Chrome existing-session via MCP @@ -352,14 +368,12 @@ Official background and setup references: - [Chrome for Developers: Use Chrome DevTools MCP with your browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) - [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp) -Create a profile: +Built-in profile: -```bash -openclaw browser create-profile \ - --name chrome-live \ - --driver existing-session \ - --color "#00AA00" -``` +- `user` + +Optional: create your own custom existing-session profile if you want a +different name or color. Then in Chrome: @@ -370,15 +384,16 @@ Then in Chrome: Live attach smoke test: ```bash -openclaw browser --browser-profile chrome-live start -openclaw browser --browser-profile chrome-live status -openclaw browser --browser-profile chrome-live tabs -openclaw browser --browser-profile chrome-live snapshot --format ai +openclaw browser --browser-profile user start +openclaw browser --browser-profile user status +openclaw browser --browser-profile user tabs +openclaw browser --browser-profile user snapshot --format ai ``` What success looks like: - `status` shows `driver: existing-session` +- `status` shows `transport: chrome-mcp` - `status` shows `running: true` - `tabs` lists your already-open Chrome tabs - `snapshot` returns refs from the selected live tab @@ -388,6 +403,15 @@ What to check if attach does not work: - Chrome is version `144+` - remote debugging is enabled at `chrome://inspect/#remote-debugging` - Chrome showed and you accepted the attach consent prompt + +Agent use: + +- Use `profile="user"` when you need the user’s logged-in browser state. +- If you use a custom existing-session profile, pass that explicit profile name. +- Prefer `profile="user"` over `profile="chrome-relay"` unless the user + explicitly wants the extension / attach-tab flow. +- Only choose this mode when the user is at the computer to approve the attach + prompt. - the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` Notes: @@ -398,6 +422,10 @@ Notes: session only. - OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not the legacy default-profile remote debugging port workflow. +- Existing-session screenshots support page captures and `--ref` element + captures from snapshots, but not CSS `--element` selectors. +- Existing-session `wait --url` supports exact, substring, and glob patterns + like other browser drivers. `wait --load networkidle` is not supported yet. - Some features still require the extension relay or managed browser path, such as PDF export and download interception. - Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. @@ -409,7 +437,7 @@ WSL2 / cross-namespace example: browser: { enabled: true, relayBindHost: "0.0.0.0", - defaultProfile: "chrome", + defaultProfile: "chrome-relay", }, } ``` diff --git a/docs/tools/btw.md b/docs/tools/btw.md new file mode 100644 index 00000000000..38a30fcec77 --- /dev/null +++ b/docs/tools/btw.md @@ -0,0 +1,142 @@ +--- +summary: "Ephemeral side questions with /btw" +read_when: + - You want to ask a quick side question about the current session + - You are implementing or debugging BTW behavior across clients +title: "BTW Side Questions" +--- + +# BTW Side Questions + +`/btw` lets you ask a quick side question about the **current session** without +turning that question into normal conversation history. + +It is modeled after Claude Code's `/btw` behavior, but adapted to OpenClaw's +Gateway and multi-channel architecture. + +## What it does + +When you send: + +```text +/btw what changed? +``` + +OpenClaw: + +1. snapshots the current session context, +2. runs a separate **tool-less** model call, +3. answers only the side question, +4. leaves the main run alone, +5. does **not** write the BTW question or answer to session history, +6. emits the answer as a **live side result** rather than a normal assistant message. + +The important mental model is: + +- same session context +- separate one-shot side query +- no tool calls +- no future context pollution +- no transcript persistence + +## What it does not do + +`/btw` does **not**: + +- create a new durable session, +- continue the unfinished main task, +- run tools or agent tool loops, +- write BTW question/answer data to transcript history, +- appear in `chat.history`, +- survive a reload. + +It is intentionally **ephemeral**. + +## How context works + +BTW uses the current session as **background context only**. + +If the main run is currently active, OpenClaw snapshots the current message +state and includes the in-flight main prompt as background context, while +explicitly telling the model: + +- answer only the side question, +- do not resume or complete the unfinished main task, +- do not emit tool calls or pseudo-tool calls. + +That keeps BTW isolated from the main run while still making it aware of what +the session is about. + +## Delivery model + +BTW is **not** delivered as a normal assistant transcript message. + +At the Gateway protocol level: + +- normal assistant chat uses the `chat` event +- BTW uses the `chat.side_result` event + +This separation is intentional. If BTW reused the normal `chat` event path, +clients would treat it like regular conversation history. + +Because BTW uses a separate live event and is not replayed from +`chat.history`, it disappears after reload. + +## Surface behavior + +### TUI + +In TUI, BTW is rendered inline in the current session view, but it remains +ephemeral: + +- visibly distinct from a normal assistant reply +- dismissible with `Enter` or `Esc` +- not replayed on reload + +### External channels + +On channels like Telegram, WhatsApp, and Discord, BTW is delivered as a +clearly labeled one-off reply because those surfaces do not have a local +ephemeral overlay concept. + +The answer is still treated as a side result, not normal session history. + +### Control UI / web + +The Gateway emits BTW correctly as `chat.side_result`, and BTW is not included +in `chat.history`, so the persistence contract is already correct for web. + +The current Control UI still needs a dedicated `chat.side_result` consumer to +render BTW live in the browser. Until that client-side support lands, BTW is a +Gateway-level feature with full TUI and external-channel behavior, but not yet +a complete browser UX. + +## When to use BTW + +Use `/btw` when you want: + +- a quick clarification about the current work, +- a factual side answer while a long run is still in progress, +- a temporary answer that should not become part of future session context. + +Examples: + +```text +/btw what file are we editing? +/btw what does this error mean? +/btw summarize the current task in one sentence +/btw what is 17 * 19? +``` + +## When not to use BTW + +Do not use `/btw` when you want the answer to become part of the session's +future working context. + +In that case, ask normally in the main session instead of using BTW. + +## Related + +- [Slash commands](/tools/slash-commands) +- [Thinking Levels](/tools/thinking) +- [Session](/concepts/session) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index dcf2150409b..91a6c1240f1 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -62,7 +62,7 @@ After upgrading OpenClaw: ## Use it (set gateway token once) -OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. +OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port. Before first attach, open extension Options and set: @@ -71,8 +71,8 @@ Before first attach, open extension Options and set: Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `profile="chrome"` +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` If you want a different name or a different relay port, create your own profile: diff --git a/docs/tools/index.md b/docs/tools/index.md index 6552d6f9118..bdd9b78456f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -316,7 +316,11 @@ Common parameters: Notes: - Requires `browser.enabled=true` (default is `true`; set `false` to disable). - All actions accept optional `profile` parameter for multi-instance support. -- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome"). +- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). +- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. +- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets. +- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). - Profile names: lowercase alphanumeric + hyphens only (max 64 chars). - Port range: 18800-18899 (~100 profiles max). - Remote profiles are attach-only (no start/stop/reset). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index e0a9f1aa365..19072342b20 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -76,6 +76,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) +- `/btw ` (ask an ephemeral side question about the current session without changing future session context; see [/tools/btw](/tools/btw)) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) @@ -223,3 +224,27 @@ Notes: - **`/stop`** targets the active chat session so it can abort the current run. - **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons. - Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages. + +## BTW side questions + +`/btw` is a quick **side question** about the current session. + +Unlike normal chat: + +- it uses the current session as background context, +- it runs as a separate **tool-less** one-shot call, +- it does not change future session context, +- it is not written to transcript history, +- it is delivered as a live side result instead of a normal assistant message. + +That makes `/btw` useful when you want a temporary clarification while the main +task keeps going. + +Example: + +```text +/btw what are we doing right now? +``` + +See [BTW Side Questions](/tools/btw) for the full behavior and client UX +details. diff --git a/docs/zh-CN/automation/cron-jobs.md b/docs/zh-CN/automation/cron-jobs.md index 185779a2636..cfdb0c178e1 100644 --- a/docs/zh-CN/automation/cron-jobs.md +++ b/docs/zh-CN/automation/cron-jobs.md @@ -28,7 +28,9 @@ x-i18n: - 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。 - 两种执行方式: - **主会话**:入队一个系统事件,然后在下一次心跳时运行。 - - **隔离式**:在 `cron:` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **隔离式**:在 `cron:` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。 + - **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。 - 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 ## 快速开始(可操作) @@ -83,6 +85,14 @@ openclaw cron add \ 2. **选择运行位置** - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 - `sessionTarget: "isolated"` → 在 `cron:` 中运行专用智能体轮次。 + - `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:`)。 + - `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。 + + 默认行为(保持不变): + - `systemEvent` 负载默认使用 `main` + - `agentTurn` 负载默认使用 `isolated` + + 要使用当前会话绑定,需显式设置 `sessionTarget: "current"`。 3. **选择负载** - 主会话 → `payload.kind = "systemEvent"` @@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主 #### 隔离任务(专用定时会话) -隔离任务在会话 `cron:` 中运行专用智能体轮次。 +隔离任务在会话 `cron:` 或自定义会话中运行专用智能体轮次。 关键行为: - 提示以 `[cron: <任务名称>]` 为前缀,便于追踪。 -- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。 +- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。 +- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。 - 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。 - `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。 diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 66780c709b1..d3947cc7552 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index b2c13701ead..67df516b8d7 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index cbd8a74d807..c5392fd2595 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { postMultipartFormData } from "./multipart.js"; +import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, @@ -262,12 +262,7 @@ export async function sendBlueBubblesAttachment(params: { timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error( - `BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertMultipartActionOk(res, "attachment send"); const responseBody = await res.text(); if (!responseBody) { diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index cc37829bc9d..f8adc9b86fd 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -29,6 +29,11 @@ describe("chat", () => { }); } + function mockTwoOkTextResponses() { + mockOkTextResponse(); + mockOkTextResponse(); + } + async function expectCalledUrlIncludesPassword(params: { password: string; invoke: () => Promise; @@ -198,15 +203,7 @@ describe("chat", () => { }); it("uses POST for start and DELETE for stop", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); + mockTwoOkTextResponses(); await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { serverUrl: "http://localhost:1234", @@ -442,15 +439,7 @@ describe("chat", () => { }); it("adds and removes participant using matching endpoint", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); + mockTwoOkTextResponses(); await addBlueBubblesParticipant("chat-guid", "+15551234567", { serverUrl: "http://localhost:1234", diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 1670f276ba7..17340b7f980 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { postMultipartFormData } from "./multipart.js"; +import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -26,14 +26,6 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } -async function assertBlueBubblesActionOk(response: Response, action: string): Promise { - if (response.ok) { - return; - } - const errorText = await response.text().catch(() => ""); - throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`); -} - function resolvePartIndex(partIndex: number | undefined): number { return typeof partIndex === "number" ? partIndex : 0; } @@ -63,7 +55,7 @@ async function sendBlueBubblesChatEndpointRequest(params: { { method: params.method }, params.opts.timeoutMs, ); - await assertBlueBubblesActionOk(res, params.action); + await assertMultipartActionOk(res, params.action); } async function sendPrivateApiJsonRequest(params: { @@ -89,7 +81,7 @@ async function sendPrivateApiJsonRequest(params: { } const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); - await assertBlueBubblesActionOk(res, params.action); + await assertMultipartActionOk(res, params.action); } export async function markBlueBubblesChatRead( @@ -327,8 +319,5 @@ export async function setGroupIconBlueBubbles( timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads }); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`); - } + await assertMultipartActionOk(res, "setGroupIcon"); } diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts index 3e06302593c..62651279237 100644 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; +function createFallbackDmPayload(overrides: Record = {}) { + return { + guid: "msg-1", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + ...overrides, + }; +} + describe("normalizeWebhookMessage", () => { it("falls back to DM chatGuid handle when sender handle is missing", () => { const result = normalizeWebhookMessage({ type: "new-message", - data: { - guid: "msg-1", + data: createFallbackDmPayload({ text: "hello", - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); @@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => { it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { const result = normalizeWebhookReaction({ type: "updated-message", - data: { + data: createFallbackDmPayload({ guid: "msg-2", associatedMessageGuid: "p:0/msg-1", associatedMessageType: 2000, - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); diff --git a/extensions/bluebubbles/src/multipart.ts b/extensions/bluebubbles/src/multipart.ts index 851cca016b7..e7c840745bb 100644 --- a/extensions/bluebubbles/src/multipart.ts +++ b/extensions/bluebubbles/src/multipart.ts @@ -30,3 +30,11 @@ export async function postMultipartFormData(params: { params.timeoutMs, ); } + +export async function assertMultipartActionOk(response: Response, action: string): Promise { + if (response.ok) { + return; + } + const errorText = await response.text().catch(() => ""); + throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`); +} diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 9829860d042..fdab55b3da8 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.13", + "version": "2026.3.14", "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 825d1668ac0..7ba88842a7a 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -108,13 +108,21 @@ function resolveScheme( return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; } -function isPrivateIPv4(address: string): boolean { +function parseIPv4Octets(address: string): [number, number, number, number] | null { const parts = address.split("."); - if (parts.length != 4) { - return false; + if (parts.length !== 4) { + return null; } const octets = parts.map((part) => Number.parseInt(part, 10)); if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + return null; + } + return octets as [number, number, number, number]; +} + +function isPrivateIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; @@ -131,12 +139,8 @@ function isPrivateIPv4(address: string): boolean { } function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 95eea6a702a..b51ead550ef 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index df0a0a79192..c38da12bfcd 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,6 +1,8 @@ import type { IncomingMessage } from "node:http"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { @@ -9,33 +11,19 @@ describe("diffs plugin registration", () => { const registerHttpRoute = vi.fn(); const on = vi.fn(); - plugin.register?.({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: {}, - runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool, - registerHook() {}, - registerHttpRoute, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on, - }); + plugin.register?.( + createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: {}, + runtime: {} as never, + registerTool, + registerHttpRoute, + on, + }), + ); expect(registerTool).toHaveBeenCalledTimes(1); expect(registerHttpRoute).toHaveBeenCalledTimes(1); @@ -55,17 +43,15 @@ describe("diffs plugin registration", () => { }); it("applies plugin-config defaults through registered tool and viewer handler", async () => { - let registeredTool: - | { execute?: (toolCallId: string, params: Record) => Promise } - | undefined; - let registeredHttpRouteHandler: - | (( - req: IncomingMessage, - res: ReturnType, - ) => Promise) - | undefined; + type RegisteredTool = { + execute?: (toolCallId: string, params: Record) => Promise; + }; + type RegisteredHttpRouteParams = Parameters[0]; - plugin.register?.({ + let registeredTool: RegisteredTool | undefined; + let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; + + const api = createTestPluginApi({ id: "diffs", name: "Diffs", description: "Diffs", @@ -88,31 +74,16 @@ describe("diffs plugin registration", () => { }, }, runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool(tool) { + registerTool(tool: Parameters[0]) { registeredTool = typeof tool === "function" ? undefined : tool; }, - registerHook() {}, - registerHttpRoute(params) { - registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; + registerHttpRoute(params: RegisteredHttpRouteParams) { + registeredHttpRouteHandler = params.handler; }, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, }); + plugin.register?.(api as unknown as OpenClawPluginApi); + const result = await registeredTool?.execute?.("tool-1", { before: "one\n", after: "two\n", diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index bb5f232517a..b92b16052b8 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.13", + "version": "2026.3.14", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", @@ -8,7 +8,7 @@ "build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js" }, "dependencies": { - "@pierre/diffs": "1.0.11", + "@pierre/diffs": "1.1.0", "@sinclair/typebox": "0.34.48", "playwright-core": "1.58.2" }, diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 43216580379..a1caef018e4 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -9,6 +9,19 @@ describe("createDiffsHttpHandler", () => { let store: DiffArtifactStore; let cleanupRootDir: () => Promise; + async function handleLocalGet(url: string) { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url, + }), + res, + ); + return { handled, res }; + } + beforeEach(async () => { ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); @@ -19,16 +32,7 @@ describe("createDiffsHttpHandler", () => { it("serves a stored diff document", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); + const { handled, res } = await handleLocalGet(artifact.viewerPath); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -38,15 +42,8 @@ describe("createDiffsHttpHandler", () => { it("rejects invalid tokens", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath.replace(artifact.token, "bad-token"), - }), - res, + const { handled, res } = await handleLocalGet( + artifact.viewerPath.replace(artifact.token, "bad-token"), ); expect(handled).toBe(true); diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index f46a2c9abe9..006b239a39f 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -23,8 +23,7 @@ describe("renderDiffDocument", () => { expect(rendered.html).toContain("data-openclaw-diff-root"); expect(rendered.html).toContain("src/example.ts"); expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).not.toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"'); + expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js"); expect(rendered.imageHtml).toContain("max-width: 960px;"); expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); expect(rendered.html).toContain("min-height: 100vh;"); diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index fb3d089c90a..364252c0b3b 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -1,5 +1,12 @@ -import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs"; -import { parsePatchFiles } from "@pierre/diffs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { + FileContents, + FileDiffMetadata, + SupportedLanguages, + ThemeRegistrationResolved, +} from "@pierre/diffs"; +import { RegisteredCustomThemes, parsePatchFiles } from "@pierre/diffs"; import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr"; import type { DiffInput, @@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js"; const DEFAULT_FILE_NAME = "diff.txt"; const MAX_PATCH_FILE_COUNT = 128; const MAX_PATCH_TOTAL_LINES = 120_000; +const diffsRequire = createRequire(import.meta.resolve("@pierre/diffs")); + +let pierreThemesPatched = false; + +function createThemeLoader( + themeName: "pierre-dark" | "pierre-light", + themePath: string, +): () => Promise { + let cachedTheme: ThemeRegistrationResolved | undefined; + return async () => { + if (cachedTheme) { + return cachedTheme; + } + const raw = await fs.readFile(themePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + cachedTheme = { + ...parsed, + name: themeName, + } as ThemeRegistrationResolved; + return cachedTheme; + }; +} + +function patchPierreThemeLoadersForNode24(): void { + if (pierreThemesPatched) { + return; + } + try { + const darkThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-dark.json"); + const lightThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-light.json"); + RegisteredCustomThemes.set("pierre-dark", createThemeLoader("pierre-dark", darkThemePath)); + RegisteredCustomThemes.set("pierre-light", createThemeLoader("pierre-light", lightThemePath)); + pierreThemesPatched = true; + } catch { + // Keep upstream loaders if theme files cannot be resolved. + } +} + +patchPierreThemeLoadersForNode24(); function escapeCssString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); @@ -195,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string { `; } -function renderStaticDiffCard(prerenderedHTML: string): string { - return `
- - - -
`; -} - function buildHtmlDocument(params: { title: string; bodyHtml: string; @@ -211,7 +249,7 @@ function buildHtmlDocument(params: { runtimeMode: "viewer" | "image"; }): string { return ` - + @@ -303,7 +341,7 @@ function buildHtmlDocument(params: { ${params.bodyHtml} - ${params.runtimeMode === "viewer" ? `` : ""} + `; } @@ -314,16 +352,12 @@ type RenderedSection = { }; function buildRenderedSection(params: { - viewerPrerenderedHtml: string; - imagePrerenderedHtml: string; - payload: Omit; + viewerPayload: DiffViewerPayload; + imagePayload: DiffViewerPayload; }): RenderedSection { return { - viewer: renderDiffCard({ - prerenderedHTML: params.viewerPrerenderedHtml, - ...params.payload, - }), - image: renderStaticDiffCard(params.imagePrerenderedHtml), + viewer: renderDiffCard(params.viewerPayload), + image: renderDiffCard(params.imagePayload), }; } @@ -355,21 +389,20 @@ async function renderBeforeAfterDiff( }; const { viewerOptions, imageOptions } = buildRenderVariants(options); const [viewerResult, imageResult] = await Promise.all([ - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: viewerOptions, }), - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: imageOptions, }), ]); const section = buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, options: viewerOptions, @@ -378,6 +411,16 @@ async function renderBeforeAfterDiff( newFile: viewerResult.newFile, }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + options: imageOptions, + langs: buildPayloadLanguages({ + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + }), + }, }); return { @@ -410,24 +453,29 @@ async function renderPatchDiff( const sections = await Promise.all( files.map(async (fileDiff) => { const [viewerResult, imageResult] = await Promise.all([ - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: viewerOptions, }), - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: imageOptions, }), ]); return buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, fileDiff: viewerResult.fileDiff, options: viewerOptions, langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + fileDiff: imageResult.fileDiff, + options: imageOptions, + langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }), + }, }); }), ); @@ -468,3 +516,49 @@ export async function renderDiffDocument( inputKind: input.kind, }; } + +type PreloadedFileDiffResult = Awaited>; +type PreloadedMultiFileDiffResult = Awaited>; + +function shouldFallbackToClientHydration(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.includes('needs an import attribute of "type: json"') + ); +} + +async function preloadFileDiffWithFallback(params: { + fileDiff: FileDiffMetadata; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + fileDiff: params.fileDiff, + prerenderedHTML: "", + }; + } +} + +async function preloadMultiFileDiffWithFallback(params: { + oldFile: FileContents; + newFile: FileContents; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadMultiFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + oldFile: params.oldFile, + newFile: params.newFile, + prerenderedHTML: "", + }; + } +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 416bdf8dc14..2f845727274 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test-utils/plugin-api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; @@ -56,7 +57,7 @@ describe("diffs tool", () => { const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -135,9 +136,7 @@ describe("diffs tool", () => { mode: "file", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("honors ttlSeconds for artifact-only file output", async () => { @@ -227,9 +226,7 @@ describe("diffs tool", () => { after: "two\n", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("falls back to view output when both mode cannot render an image", async () => { @@ -335,13 +332,13 @@ describe("diffs tool", () => { const html = await store.readHtml(id); expect(html).toContain('body data-theme="light"'); expect(html).toContain("--diffs-font-size: 17px;"); - expect(html).toContain('--diffs-font-family: "JetBrains Mono"'); + expect(html).toContain("JetBrains Mono"); }); it("prefers explicit tool params over configured defaults", async () => { const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -387,7 +384,7 @@ describe("diffs tool", () => { }); function createApi(): OpenClawPluginApi { - return { + return createTestPluginApi({ id: "diffs", name: "Diffs", description: "Diffs", @@ -399,26 +396,7 @@ function createApi(): OpenClawPluginApi { }, }, runtime: {} as OpenClawPluginApi["runtime"], - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool() {}, - registerHook() {}, - registerHttpRoute() {}, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, - }; + }) as OpenClawPluginApi; } function createToolWithScreenshotter( @@ -434,6 +412,15 @@ function createToolWithScreenshotter( }); } +function expectArtifactOnlyFileResult( + screenshotter: DiffScreenshotter, + result: { details?: unknown } | null | undefined, +) { + expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); + expect((result?.details as Record).mode).toBe("file"); + expect((result?.details as Record).viewerUrl).toBeUndefined(); +} + function createPngScreenshotter( params: { assertHtml?: (html: string) => void; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 337e6fd90a5..a85eb37b85f 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.13", + "version": "2026.3.14", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/src/discord/account-inspect.test.ts b/extensions/discord/src/account-inspect.test.ts similarity index 98% rename from src/discord/account-inspect.test.ts rename to extensions/discord/src/account-inspect.test.ts index 0e8303635f9..eda0b6cc0e0 100644 --- a/src/discord/account-inspect.test.ts +++ b/extensions/discord/src/account-inspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { inspectDiscordAccount } from "./account-inspect.js"; function asConfig(value: unknown): OpenClawConfig { diff --git a/src/discord/account-inspect.ts b/extensions/discord/src/account-inspect.ts similarity index 90% rename from src/discord/account-inspect.ts rename to extensions/discord/src/account-inspect.ts index 53357ffd636..d99f87aeb56 100644 --- a/src/discord/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,7 +1,10 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig } from "../config/types.discord.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../src/config/types.discord.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/src/discord/accounts.test.ts b/extensions/discord/src/accounts.test.ts similarity index 100% rename from src/discord/accounts.test.ts rename to extensions/discord/src/accounts.test.ts diff --git a/src/discord/accounts.ts b/extensions/discord/src/accounts.ts similarity index 86% rename from src/discord/accounts.ts rename to extensions/discord/src/accounts.ts index b4e71c78343..6cd1699f192 100644 --- a/src/discord/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,9 +1,9 @@ -import { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts new file mode 100644 index 00000000000..80cd97217ae --- /dev/null +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -0,0 +1,451 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../../../src/agents/tools/discord-actions-moderation-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; + +type Ctx = Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" +>; + +export async function tryHandleDiscordMessageActionGuildAdmin(params: { + ctx: Ctx; + resolveChannelId: () => string; + readParentIdParam: (params: Record) => string | null | undefined; +}): Promise | undefined> { + const { ctx, resolveChannelId, readParentIdParam } = params; + const { action, params: actionParams, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "role-info") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "roleInfo", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "emojiList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "emojiName", { required: true }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(actionParams, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + accountId: accountId ?? undefined, + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "stickerName", { + required: true, + }); + const description = readStringParam(actionParams, "stickerDesc", { + required: true, + }); + const tags = readStringParam(actionParams, "stickerTags", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + accountId: accountId ?? undefined, + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + const roleId = readStringParam(actionParams, "roleId", { required: true }); + return await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + accountId: accountId ?? undefined, + guildId, + userId, + roleId, + }, + cfg, + ); + } + + if (action === "channel-info") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "channel-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const type = readNumberParam(actionParams, "type", { integer: true }); + const parentId = readParentIdParam(actionParams); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + return await handleDiscordAction( + { + action: "channelCreate", + accountId: accountId ?? undefined, + guildId, + name, + type: type ?? undefined, + parentId: parentId ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + nsfw, + }, + cfg, + ); + } + + if (action === "channel-edit") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const parentId = readParentIdParam(actionParams); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", { + integer: true, + }); + const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined; + const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined; + const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", { + integer: true, + }); + const availableTags = parseAvailableTags(actionParams.availableTags); + return await handleDiscordAction( + { + action: "channelEdit", + accountId: accountId ?? undefined, + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId: parentId === undefined ? undefined : parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + archived, + locked, + autoArchiveDuration: autoArchiveDuration ?? undefined, + availableTags, + }, + cfg, + ); + } + + if (action === "channel-delete") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelDelete", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-move") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const parentId = readParentIdParam(actionParams); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "channelMove", + accountId: accountId ?? undefined, + guildId, + channelId, + parentId: parentId === undefined ? undefined : parentId, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryCreate", + accountId: accountId ?? undefined, + guildId, + name, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-edit") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryEdit", + accountId: accountId ?? undefined, + categoryId, + name: name ?? undefined, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-delete") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + return await handleDiscordAction( + { action: "categoryDelete", accountId: accountId ?? undefined, categoryId }, + cfg, + ); + } + + if (action === "voice-status") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "eventList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "event-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "eventName", { required: true }); + const startTime = readStringParam(actionParams, "startTime", { + required: true, + }); + const endTime = readStringParam(actionParams, "endTime"); + const description = readStringParam(actionParams, "desc"); + const channelId = readStringParam(actionParams, "channelId"); + const location = readStringParam(actionParams, "location"); + const entityType = readStringParam(actionParams, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + accountId: accountId ?? undefined, + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (isDiscordModerationAction(action)) { + const moderation = readDiscordModerationCommand(action, { + ...actionParams, + durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }), + deleteMessageDays: readNumberParam(actionParams, "deleteDays", { + integer: true, + }), + }); + const senderUserId = ctx.requesterSenderId?.trim() || undefined; + return await handleDiscordAction( + { + action: moderation.action, + accountId: accountId ?? undefined, + guildId: moderation.guildId, + userId: moderation.userId, + durationMinutes: moderation.durationMinutes, + until: moderation.until, + reason: moderation.reason, + deleteMessageDays: moderation.deleteMessageDays, + senderUserId, + }, + cfg, + ); + } + + // Some actions are conceptually "admin", but still act on a resolved channel. + if (action === "thread-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId"); + const includeArchived = + typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined; + const before = readStringParam(actionParams, "before"); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + accountId: accountId ?? undefined, + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + const content = readStringParam(actionParams, "message", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const replyTo = readStringParam(actionParams, "replyTo"); + + // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. + // Prefer `threadId` when present to avoid accidentally replying in the parent channel. + const threadId = readStringParam(actionParams, "threadId"); + const channelId = threadId ?? resolveChannelId(); + + return await handleDiscordAction( + { + action: "threadReply", + accountId: accountId ?? undefined, + channelId, + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const query = readStringParam(actionParams, "query", { required: true }); + return await handleDiscordAction( + { + action: "searchMessages", + accountId: accountId ?? undefined, + guildId, + content: query, + channelId: readStringParam(actionParams, "channelId"), + channelIds: readStringArrayParam(actionParams, "channelIds"), + authorId: readStringParam(actionParams, "authorId"), + authorIds: readStringArrayParam(actionParams, "authorIds"), + limit: readNumberParam(actionParams, "limit", { integer: true }), + }, + cfg, + ); + } + + return undefined; +} diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts new file mode 100644 index 00000000000..b0842ce25b2 --- /dev/null +++ b/extensions/discord/src/actions/handle-action.ts @@ -0,0 +1,295 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; +import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; +import { resolveDiscordChannelId } from "../targets.js"; +import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; + +const providerId = "discord"; + +export async function handleDiscordMessageAction( + ctx: Pick< + ChannelMessageActionContext, + | "action" + | "params" + | "cfg" + | "accountId" + | "requesterSenderId" + | "toolContext" + | "mediaLocalRoots" + >, +): Promise> { + const { action, params, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(params, "accountId"); + const actionOptions = { + mediaLocalRoots: ctx.mediaLocalRoots, + } as const; + + const resolveChannelId = () => + resolveDiscordChannelId( + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), + ); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const asVoice = readBooleanParam(params, "asVoice") === true; + const rawComponents = params.components; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; + const content = readStringParam(params, "message", { + required: !asVoice && !hasComponents, + allowEmpty: true, + }); + // Support media, path, and filePath for media URL + const mediaUrl = + readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "path", { trim: false }) ?? + readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); + const replyTo = readStringParam(params, "replyTo"); + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; + const silent = readBooleanParam(params, "silent") === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + return await handleDiscordAction( + { + action: "sendMessage", + accountId: accountId ?? undefined, + to, + content, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + replyTo: replyTo ?? undefined, + components, + embeds, + asVoice, + silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + return await handleDiscordAction( + { + action: "poll", + accountId: accountId ?? undefined, + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "react") { + const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext }); + const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : ""; + if (!messageId) { + throw new Error( + "messageId required. Provide messageId explicitly or react to the current inbound message.", + ); + } + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleDiscordAction( + { + action: "react", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + emoji, + remove, + }, + cfg, + actionOptions, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "reactions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + limit, + }, + cfg, + actionOptions, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "readMessages", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }, + cfg, + actionOptions, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleDiscordAction( + { + action: "editMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + content, + }, + cfg, + actionOptions, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: "deleteMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "permissions") { + return await handleDiscordAction( + { + action: "permissions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + }, + cfg, + actionOptions, + ); + } + + if (action === "thread-create") { + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "message"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + const appliedTags = readStringArrayParam(params, "appliedTags"); + return await handleDiscordAction( + { + action: "threadCreate", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + name, + messageId, + content, + autoArchiveMinutes, + appliedTags: appliedTags ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "sticker") { + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + return await handleDiscordAction( + { + action: "sticker", + accountId: accountId ?? undefined, + to: readStringParam(params, "to", { required: true }), + stickerIds, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "set-presence") { + return await handleDiscordAction( + { + action: "setPresence", + accountId: accountId ?? undefined, + status: readStringParam(params, "status"), + activityType: readStringParam(params, "activityType"), + activityName: readStringParam(params, "activityName"), + activityUrl: readStringParam(params, "activityUrl"), + activityState: readStringParam(params, "activityState"), + }, + cfg, + actionOptions, + ); + } + + const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ + ctx, + resolveChannelId, + readParentIdParam: readDiscordParentIdParam, + }); + if (adminResult !== undefined) { + return adminResult; + } + + throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`); +} diff --git a/src/discord/api.test.ts b/extensions/discord/src/api.test.ts similarity index 96% rename from src/discord/api.test.ts rename to extensions/discord/src/api.test.ts index 4c9f1a9c0c1..5b0e648aa1d 100644 --- a/src/discord/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/src/discord/api.ts b/extensions/discord/src/api.ts similarity index 97% rename from src/discord/api.ts rename to extensions/discord/src/api.ts index f8a88a50252..cead5eb8cea 100644 --- a/src/discord/api.ts +++ b/extensions/discord/src/api.ts @@ -1,5 +1,5 @@ -import { resolveFetch } from "../infra/fetch.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { diff --git a/src/discord/audit.test.ts b/extensions/discord/src/audit.test.ts similarity index 92% rename from src/discord/audit.test.ts rename to extensions/discord/src/audit.test.ts index 55339b03381..c1b276f320b 100644 --- a/src/discord/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -27,7 +27,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, @@ -73,7 +73,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); @@ -98,7 +98,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual([]); @@ -127,7 +127,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); diff --git a/src/discord/audit.ts b/extensions/discord/src/audit.ts similarity index 96% rename from src/discord/audit.ts rename to extensions/discord/src/audit.ts index d2a6477e47f..a5a226c5550 100644 --- a/src/discord/audit.ts +++ b/extensions/discord/src/audit.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; -import { isRecord } from "../utils.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js"; +import { isRecord } from "../../../src/utils.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts new file mode 100644 index 00000000000..bf35b788e3e --- /dev/null +++ b/extensions/discord/src/channel-actions.ts @@ -0,0 +1,140 @@ +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { DiscordActionConfig } from "../../../src/config/types.discord.js"; +import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; +import { handleDiscordMessageAction } from "./actions/handle-action.js"; + +export const discordMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + if (isEnabled("polls")) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (isEnabled("permissions")) { + actions.add("permissions"); + } + if (isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (isEnabled("search")) { + actions.add("search"); + } + if (isEnabled("stickers")) { + actions.add("sticker"); + } + if (isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (isEnabled("reactions")) { + actions.add("emoji-list"); + } + if (isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (isEnabled("presence", false)) { + actions.add("set-presence"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action === "sendMessage") { + const to = typeof args.to === "string" ? args.to : undefined; + return to ? { to } : null; + } + if (action === "threadReply") { + const channelId = typeof args.channelId === "string" ? args.channelId.trim() : ""; + return channelId ? { to: `channel:${channelId}` } : null; + } + return null; + }, + handleAction: async ({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }) => { + return await handleDiscordMessageAction({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }); + }, +}; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c6852a63469..dff426ab2e4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -37,8 +37,13 @@ import { type ChannelPlugin, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; +type DiscordSendFn = ReturnType< + typeof getDiscordRuntime +>["channel"]["discord"]["sendMessageDiscord"]; + const meta = getChatChannelMeta("discord"); const discordMessageActions: ChannelMessageActionAdapter = { @@ -300,7 +305,9 @@ export const discordPlugin: ChannelPlugin = { pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, @@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin = { replyToId, silent, }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, diff --git a/src/discord/chunk.test.ts b/extensions/discord/src/chunk.test.ts similarity index 98% rename from src/discord/chunk.test.ts rename to extensions/discord/src/chunk.test.ts index d33262c4767..3c667c0fc9f 100644 --- a/src/discord/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; +import { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/src/discord/chunk.ts b/extensions/discord/src/chunk.ts similarity index 98% rename from src/discord/chunk.ts rename to extensions/discord/src/chunk.ts index 242d5c74c2d..a814c10d2c8 100644 --- a/src/discord/chunk.ts +++ b/extensions/discord/src/chunk.ts @@ -1,4 +1,4 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js"; export type ChunkDiscordTextOpts = { /** Max characters per Discord message. Default: 2000. */ diff --git a/src/discord/client.test.ts b/extensions/discord/src/client.test.ts similarity index 96% rename from src/discord/client.test.ts rename to extensions/discord/src/client.test.ts index 3dc156670e7..416fa7c903a 100644 --- a/src/discord/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; describe("createDiscordRestClient", () => { diff --git a/src/discord/client.ts b/extensions/discord/src/client.ts similarity index 90% rename from src/discord/client.ts rename to extensions/discord/src/client.ts index 62d917cebb6..2e8d53799a6 100644 --- a/src/discord/client.ts +++ b/extensions/discord/src/client.ts @@ -1,8 +1,8 @@ import { RequestClient } from "@buape/carbon"; -import { loadConfig } from "../config/config.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; -import type { RetryConfig } from "../infra/retry.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDiscordAccount, diff --git a/src/discord/components-registry.ts b/extensions/discord/src/components-registry.ts similarity index 100% rename from src/discord/components-registry.ts rename to extensions/discord/src/components-registry.ts diff --git a/src/discord/components.test.ts b/extensions/discord/src/components.test.ts similarity index 100% rename from src/discord/components.test.ts rename to extensions/discord/src/components.test.ts diff --git a/src/discord/components.ts b/extensions/discord/src/components.ts similarity index 100% rename from src/discord/components.ts rename to extensions/discord/src/components.ts diff --git a/src/discord/directory-cache.ts b/extensions/discord/src/directory-cache.ts similarity index 97% rename from src/discord/directory-cache.ts rename to extensions/discord/src/directory-cache.ts index 4cb17865eae..d1a85767216 100644 --- a/src/discord/directory-cache.ts +++ b/extensions/discord/src/directory-cache.ts @@ -1,4 +1,4 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/account-id.js"; const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000; const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/; diff --git a/src/discord/directory-live.test.ts b/extensions/discord/src/directory-live.test.ts similarity index 97% rename from src/discord/directory-live.test.ts rename to extensions/discord/src/directory-live.test.ts index e6f19d448d8..8ba3bc52c4a 100644 --- a/src/discord/directory-live.test.ts +++ b/extensions/discord/src/directory-live.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; const mocks = vi.hoisted(() => ({ fetchDiscord: vi.fn(), diff --git a/src/discord/directory-live.ts b/extensions/discord/src/directory-live.ts similarity index 95% rename from src/discord/directory-live.ts rename to extensions/discord/src/directory-live.ts index d57d3e775a9..af55475a43e 100644 --- a/src/discord/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/src/discord/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts similarity index 78% rename from src/discord/draft-chunking.ts rename to extensions/discord/src/draft-chunking.ts index 76231bc8397..ce4048379d1 100644 --- a/src/discord/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,8 +1,8 @@ -import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { getChannelDock } from "../channels/dock.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { getChannelDock } from "../../../src/channels/dock.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200; const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800; diff --git a/src/discord/draft-stream.ts b/extensions/discord/src/draft-stream.ts similarity index 97% rename from src/discord/draft-stream.ts rename to extensions/discord/src/draft-stream.ts index 0281d4c0227..db9089f6176 100644 --- a/src/discord/draft-stream.ts +++ b/extensions/discord/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createFinalizableDraftLifecycle } from "../channels/draft-stream-controls.js"; +import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/src/discord/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts similarity index 72% rename from src/discord/exec-approvals.ts rename to extensions/discord/src/exec-approvals.ts index f4be9a22e0c..5640805705a 100644 --- a/src/discord/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,6 +1,6 @@ -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; import { resolveDiscordAccount } from "./accounts.js"; export function isDiscordExecApprovalClientEnabled(params: { diff --git a/src/discord/gateway-logging.test.ts b/extensions/discord/src/gateway-logging.test.ts similarity index 96% rename from src/discord/gateway-logging.test.ts rename to extensions/discord/src/gateway-logging.test.ts index 762cf5d160b..e6fc4d0f714 100644 --- a/src/discord/gateway-logging.test.ts +++ b/extensions/discord/src/gateway-logging.test.ts @@ -1,11 +1,11 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("../globals.js", () => ({ +vi.mock("../../../src/globals.js", () => ({ logVerbose: vi.fn(), })); -import { logVerbose } from "../globals.js"; +import { logVerbose } from "../../../src/globals.js"; import { attachDiscordGatewayLogging } from "./gateway-logging.js"; const makeRuntime = () => ({ diff --git a/src/discord/gateway-logging.ts b/extensions/discord/src/gateway-logging.ts similarity index 94% rename from src/discord/gateway-logging.ts rename to extensions/discord/src/gateway-logging.ts index 916952020be..18ce32909ef 100644 --- a/src/discord/gateway-logging.ts +++ b/extensions/discord/src/gateway-logging.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from "node:events"; -import { logVerbose } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; type GatewayEmitter = Pick; diff --git a/src/discord/guilds.ts b/extensions/discord/src/guilds.ts similarity index 100% rename from src/discord/guilds.ts rename to extensions/discord/src/guilds.ts diff --git a/src/discord/mentions.test.ts b/extensions/discord/src/mentions.test.ts similarity index 100% rename from src/discord/mentions.test.ts rename to extensions/discord/src/mentions.test.ts diff --git a/src/discord/mentions.ts b/extensions/discord/src/mentions.ts similarity index 100% rename from src/discord/mentions.ts rename to extensions/discord/src/mentions.ts diff --git a/src/discord/monitor.gateway.test.ts b/extensions/discord/src/monitor.gateway.test.ts similarity index 100% rename from src/discord/monitor.gateway.test.ts rename to extensions/discord/src/monitor.gateway.test.ts diff --git a/src/discord/monitor.gateway.ts b/extensions/discord/src/monitor.gateway.ts similarity index 100% rename from src/discord/monitor.gateway.ts rename to extensions/discord/src/monitor.gateway.ts diff --git a/src/discord/monitor.test.ts b/extensions/discord/src/monitor.test.ts similarity index 97% rename from src/discord/monitor.test.ts rename to extensions/discord/src/monitor.test.ts index 9471a3fe6bc..40f14a00551 100644 --- a/src/discord/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -1,6 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { typedCases } from "../test-utils/typed-cases.js"; +import { typedCases } from "../../../src/test-utils/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, @@ -22,7 +22,7 @@ import { DiscordMessageListener, DiscordReactionListener } from "./monitor/liste const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), })); @@ -157,7 +157,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const handler = vi.fn(async () => { throw new Error("boom"); }); @@ -178,7 +180,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const listener = new DiscordMessageListener(handler, logger); const handlePromise = listener.handle( @@ -247,6 +251,18 @@ describe("discord guild/channel resolution", () => { expect(resolved?.slug).toBe("friends-of-openclaw"); }); + it("resolves guild entry by raw guild id when guild object is missing", () => { + const guildEntries = makeEntries({ + "123": { slug: "friends-of-openclaw" }, + }); + const resolved = resolveDiscordGuildEntry({ + guildId: "123", + guildEntries, + }); + expect(resolved?.id).toBe("123"); + expect(resolved?.slug).toBe("friends-of-openclaw"); + }); + it("resolves guild entry by slug key", () => { const guildEntries = makeEntries({ "friends-of-openclaw": { slug: "friends-of-openclaw" }, @@ -876,11 +892,11 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({ })), })); -vi.mock("../infra/system-events.js", () => ({ +vi.mock("../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); -vi.mock("../routing/resolve-route.js", () => ({ +vi.mock("../../../src/routing/resolve-route.js", () => ({ resolveAgentRoute: resolveAgentRouteMock, })); @@ -961,9 +977,9 @@ function makeReactionListenerParams(overrides?: { guildEntries?: Record; }) { return { - cfg: {} as ReturnType, + cfg: {} as ReturnType, accountId: "acc-1", - runtime: {} as import("../runtime.js").RuntimeEnv, + runtime: {} as import("../../../src/runtime.js").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", dmEnabled: overrides?.dmEnabled ?? true, groupDmEnabled: overrides?.groupDmEnabled ?? true, @@ -978,7 +994,9 @@ function makeReactionListenerParams(overrides?: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - } as unknown as ReturnType, + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >, }; } diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts similarity index 96% rename from src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts rename to extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index b85ec0c060d..6461fcef756 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js"; import { dispatchMock, readAllowFromStoreMock, @@ -14,8 +14,8 @@ import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.j import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; const loadConfigMock = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: (...args: unknown[]) => loadConfigMock(...args), @@ -63,7 +63,7 @@ beforeEach(() => { const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; -type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +type LoadedConfig = ReturnType<(typeof import("../../../src/config/config.js"))["loadConfig"]>; let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler; let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand; @@ -322,7 +322,7 @@ describe("discord tool result dispatch", () => { channels: { discord: { dm: { enabled: true, policy: "open" } }, }, - } as ReturnType; + } as ReturnType; const command = createDiscordNativeCommand({ command: { @@ -451,7 +451,7 @@ describe("discord tool result dispatch", () => { const cfg = { ...createDefaultThreadConfig(), routing: { allowFrom: [] }, - } as ReturnType; + } as ReturnType; const handler = await createHandler(cfg); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts similarity index 98% rename from src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts rename to extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 70d7fd53708..d1340f49852 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -12,7 +12,7 @@ import { createDiscordMessageHandler } from "./monitor/message-handler.js"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; -type Config = ReturnType; +type Config = ReturnType; beforeEach(() => { __resetDiscordChannelInfoCacheForTest(); diff --git a/src/discord/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts similarity index 72% rename from src/discord/monitor.tool-result.test-harness.ts rename to extensions/discord/src/monitor.tool-result.test-harness.ts index 0d4596b3281..700e9a63df3 100644 --- a/src/discord/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); @@ -15,8 +15,8 @@ vi.mock("./send.js", () => ({ }, })); -vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), @@ -36,10 +36,10 @@ function createPairingStoreMocks() { }; } -vi.mock("../pairing/pairing-store.js", () => createPairingStoreMocks()); +vi.mock("../../../src/pairing/pairing-store.js", () => createPairingStoreMocks()); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), diff --git a/src/discord/monitor.ts b/extensions/discord/src/monitor.ts similarity index 100% rename from src/discord/monitor.ts rename to extensions/discord/src/monitor.ts diff --git a/src/discord/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts similarity index 96% rename from src/discord/monitor/agent-components.ts rename to extensions/discord/src/monitor/agent-components.ts index 56e7dfe3240..e954c372bb1 100644 --- a/src/discord/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -17,32 +17,35 @@ import { } from "@buape/carbon"; import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordAccountConfig } from "../../config/types.discord.js"; -import { logVerbose } from "../../globals.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { logDebug, logError } from "../../logger.js"; -import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../../src/auto-reply/envelope.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { @@ -360,6 +363,7 @@ async function ensureAgentComponentInteractionAllowed(params: { }): Promise<{ parentId: string | undefined } | null> { const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: params.rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1094,6 +1098,7 @@ async function handleDiscordComponentEvent(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1246,6 +1251,7 @@ async function handleDiscordModalTrigger(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1696,6 +1702,7 @@ class DiscordComponentModal extends Modal { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); diff --git a/src/discord/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts similarity index 100% rename from src/discord/monitor/agent-components.wildcard.test.ts rename to extensions/discord/src/monitor/agent-components.wildcard.test.ts diff --git a/src/discord/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts similarity index 94% rename from src/discord/monitor/allow-list.ts rename to extensions/discord/src/monitor/allow-list.ts index 583d4fa7cd2..6391ad5c3a5 100644 --- a/src/discord/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,12 +1,12 @@ import type { Guild, User } from "@buape/carbon"; -import type { AllowlistMatch } from "../../channels/allowlist-match.js"; +import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "../../channels/channel-config.js"; -import { evaluateGroupRouteAccessForPolicy } from "../../plugin-sdk/group-access.js"; +} from "../../../../src/channels/channel-config.js"; +import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { @@ -19,34 +19,7 @@ export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"]; -export type DiscordGuildEntryResolved = { - id?: string; - slug?: string; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - reactionNotifications?: "off" | "own" | "all" | "allowlist"; - users?: string[]; - roles?: string[]; - channels?: Record< - string, - { - allow?: boolean; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - skills?: string[]; - enabled?: boolean; - users?: string[]; - roles?: string[]; - systemPrompt?: string; - includeThreadStarter?: boolean; - autoThread?: boolean; - autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; - } - >; -}; - -export type DiscordChannelConfigResolved = { - allowed: boolean; +type DiscordChannelOverrideConfig = { requireMention?: boolean; ignoreOtherMentions?: boolean; skills?: string[]; @@ -57,6 +30,21 @@ export type DiscordChannelConfigResolved = { includeThreadStarter?: boolean; autoThread?: boolean; autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + ignoreOtherMentions?: boolean; + reactionNotifications?: "off" | "own" | "all" | "allowlist"; + users?: string[]; + roles?: string[]; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & { + allowed: boolean; matchKey?: string; matchSource?: ChannelMatchSource; }; @@ -333,25 +321,30 @@ export function resolveDiscordCommandAuthorized(params: { export function resolveDiscordGuildEntry(params: { guild?: Guild | Guild | null; + guildId?: string | null; guildEntries?: Record; }): DiscordGuildEntryResolved | null { const guild = params.guild; const entries = params.guildEntries; - if (!guild || !entries) { + const guildId = params.guildId?.trim() || guild?.id; + if (!entries) { return null; } - const byId = entries[guild.id]; + const byId = guildId ? entries[guildId] : undefined; if (byId) { - return { ...byId, id: guild.id }; + return { ...byId, id: guildId }; + } + if (!guild) { + return null; } const slug = normalizeDiscordSlug(guild.name ?? ""); const bySlug = entries[slug]; if (bySlug) { - return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; + return { ...bySlug, id: guildId ?? guild.id, slug: slug || bySlug.slug }; } const wildcard = entries["*"]; if (wildcard) { - return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; + return { ...wildcard, id: guildId ?? guild.id, slug: slug || wildcard.slug }; } return null; } diff --git a/src/discord/monitor/auto-presence.test.ts b/extensions/discord/src/monitor/auto-presence.test.ts similarity index 98% rename from src/discord/monitor/auto-presence.test.ts rename to extensions/discord/src/monitor/auto-presence.test.ts index d901a76d642..3e81b523bc9 100644 --- a/src/discord/monitor/auto-presence.test.ts +++ b/extensions/discord/src/monitor/auto-presence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { AuthProfileStore } from "../../../../src/agents/auth-profiles.js"; import { createDiscordAutoPresenceController, resolveDiscordAutoPresenceDecision, diff --git a/src/discord/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts similarity index 97% rename from src/discord/monitor/auto-presence.ts rename to extensions/discord/src/monitor/auto-presence.ts index 8c139382dc6..60e5619e348 100644 --- a/src/discord/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -6,9 +6,12 @@ import { resolveProfilesUnavailableReason, type AuthProfileFailureReason, type AuthProfileStore, -} from "../../agents/auth-profiles.js"; -import type { DiscordAccountConfig, DiscordAutoPresenceConfig } from "../../config/config.js"; -import { warn } from "../../globals.js"; +} from "../../../../src/agents/auth-profiles.js"; +import type { + DiscordAccountConfig, + DiscordAutoPresenceConfig, +} from "../../../../src/config/config.js"; +import { warn } from "../../../../src/globals.js"; import { resolveDiscordPresenceUpdate } from "./presence.js"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; diff --git a/src/discord/monitor/commands.test.ts b/extensions/discord/src/monitor/commands.test.ts similarity index 100% rename from src/discord/monitor/commands.test.ts rename to extensions/discord/src/monitor/commands.test.ts diff --git a/src/discord/monitor/commands.ts b/extensions/discord/src/monitor/commands.ts similarity index 67% rename from src/discord/monitor/commands.ts rename to extensions/discord/src/monitor/commands.ts index 96a277785df..a9bb9c1548e 100644 --- a/src/discord/monitor/commands.ts +++ b/extensions/discord/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { DiscordSlashCommandConfig } from "../../config/types.discord.js"; +import type { DiscordSlashCommandConfig } from "../../../../src/config/types.discord.js"; export function resolveDiscordSlashCommandConfig( raw?: DiscordSlashCommandConfig, diff --git a/src/discord/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts similarity index 100% rename from src/discord/monitor/dm-command-auth.test.ts rename to extensions/discord/src/monitor/dm-command-auth.test.ts diff --git a/src/discord/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts similarity index 95% rename from src/discord/monitor/dm-command-auth.ts rename to extensions/discord/src/monitor/dm-command-auth.ts index 2a9e18be0b0..2fa02d9d605 100644 --- a/src/discord/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,9 +1,9 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, type DmGroupAccessDecision, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; diff --git a/src/discord/monitor/dm-command-decision.test.ts b/extensions/discord/src/monitor/dm-command-decision.test.ts similarity index 100% rename from src/discord/monitor/dm-command-decision.test.ts rename to extensions/discord/src/monitor/dm-command-decision.test.ts diff --git a/src/discord/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts similarity index 88% rename from src/discord/monitor/dm-command-decision.ts rename to extensions/discord/src/monitor/dm-command-decision.ts index d5b533bfdaa..8c15e7cac11 100644 --- a/src/discord/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -1,5 +1,5 @@ -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; export async function handleDiscordDmCommandDecision(params: { diff --git a/src/discord/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts similarity index 92% rename from src/discord/monitor/exec-approvals.test.ts rename to extensions/discord/src/monitor/exec-approvals.test.ts index 8f9430393a2..be3ead1d400 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -4,8 +4,8 @@ import path from "node:path"; import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearSessionStoreCacheForTest } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; import { buildExecApprovalCustomId, extractDiscordChannelId, @@ -76,7 +76,7 @@ vi.mock("../send.shared.js", async (importOriginal) => { }; }); -vi.mock("../../gateway/client.js", () => ({ +vi.mock("../../../../src/gateway/client.js", () => ({ GatewayClient: class { private params: Record; constructor(params: Record) { @@ -96,11 +96,11 @@ vi.mock("../../gateway/client.js", () => ({ }, })); -vi.mock("../../gateway/connection-auth.js", () => ({ +vi.mock("../../../../src/gateway/connection-auth.js", () => ({ resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth, })); -vi.mock("../../logger.js", () => ({ +vi.mock("../../../../src/logger.js", () => ({ logDebug: vi.fn(), logError: vi.fn(), })); @@ -116,6 +116,62 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") }); } +function mockSuccessfulDmDelivery(params?: { + noteChannelId?: string; + expectedNoteText?: string; + throwOnUnexpectedRoute?: boolean; +}) { + mockRestPost.mockImplementation( + async (route: string, requestParams?: { body?: { content?: string } }) => { + if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) { + if (params.expectedNoteText) { + expect(requestParams?.body?.content).toContain(params.expectedNoteText); + } + return { id: "note-1", channel_id: params.noteChannelId }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + if (params?.throwOnUnexpectedRoute) { + throw new Error(`unexpected route: ${route}`); + } + return { id: "msg-unknown" }; + }, + ); +} + +async function expectGatewayAuthStart(params: { + handler: DiscordExecApprovalHandler; + expectedUrl: string; + expectedSource: "cli" | "env"; + expectedToken?: string; + expectedPassword?: string; +}) { + await params.handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: params.expectedUrl, + urlOverrideSource: params.expectedSource, + }), + ); + + const expectedClientParams: Record = { + url: params.expectedUrl, + }; + if (params.expectedToken !== undefined) { + expectedClientParams.token = params.expectedToken; + } + if (params.expectedPassword !== undefined) { + expectedClientParams.password = params.expectedPassword; + } + expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams)); +} + type ExecApprovalHandlerInternals = { pending: Map< string, @@ -772,15 +828,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - return { id: "msg-unknown" }; - }); + mockSuccessfulDmDelivery(); const request = createRequest({ sessionKey: "agent:main:discord:dm:123" }); await internals.handleApprovalRequested(request); @@ -809,21 +857,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation( - async (route: string, params?: { body?: { content?: string } }) => { - if (route === Routes.channelMessages("999888777")) { - expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); - return { id: "note-1", channel_id: "999888777" }; - } - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }, - ); + mockSuccessfulDmDelivery({ + noteChannelId: "999888777", + expectedNoteText: "I sent the allowed approvers DMs", + throwOnUnexpectedRoute: true, + }); await internals.handleApprovalRequested(createRequest()); @@ -853,15 +891,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }); + mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); await internals.handleApprovalRequested( createRequest({ sessionKey: "agent:main:discord:dm:123" }), @@ -890,22 +920,13 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://override.example/ws", - urlOverrideSource: "cli", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://override.example/ws", - token: "resolved-token", - password: "resolved-password", // pragma: allowlist secret - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://override.example/ws", + expectedSource: "cli", + expectedToken: "resolved-token", + expectedPassword: "resolved-password", // pragma: allowlist secret + }); await handler.stop(); }); @@ -921,20 +942,11 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://gateway-from-env.example/ws", - urlOverrideSource: "env", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://gateway-from-env.example/ws", - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://gateway-from-env.example/ws", + expectedSource: "env", + }); await handler.stop(); } finally { diff --git a/src/discord/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts similarity index 92% rename from src/discord/monitor/exec-approvals.ts rename to extensions/discord/src/monitor/exec-approvals.ts index 87dc0c9a07d..e5fda7682a9 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,24 +10,30 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; -import { GatewayClient } from "../../gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js"; -import type { EventFrame } from "../../gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js"; -import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; +import { GatewayClient } from "../../../../src/gateway/client.js"; +import { createOperatorApprovalsGatewayClient } from "../../../../src/gateway/operator-approvals-client.js"; +import type { EventFrame } from "../../../../src/gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../../../src/infra/exec-approval-command-display.js"; +import { getExecApprovalApproverDmNoticeText } from "../../../../src/infra/exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalResolved, -} from "../../infra/exec-approvals.js"; -import { logDebug, logError } from "../../logger.js"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { compileSafeRegex, testRegexWithBoundedInput } from "../../security/safe-regex.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; +} from "../../../../src/infra/exec-approvals.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { + compileSafeRegex, + testRegexWithBoundedInput, +} from "../../../../src/security/safe-regex.js"; +import { normalizeMessageChannel } from "../../../../src/utils/message-channel.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -252,17 +258,30 @@ function formatOptionalCommandPreview( return formatCommandPreview(commandText, maxChars); } +function resolveExecApprovalPreviews( + request: ExecApprovalRequest["request"], + maxChars: number, + secondaryMaxChars: number, +): { commandPreview: string; commandSecondaryPreview: string | null } { + const { commandText, commandPreview: secondaryPreview } = + resolveExecApprovalCommandDisplay(request); + return { + commandPreview: formatCommandPreview(commandText, maxChars), + commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars), + }; +} + function createExecApprovalRequestContainer(params: { request: ExecApprovalRequest; cfg: OpenClawConfig; accountId: string; actionRow?: Row