diff --git a/.gitattributes b/.gitattributes index 6313b56c5784..54fc4c9b14d8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto eol=lf +CLAUDE.md -text +src/gateway/server-methods/CLAUDE.md -text diff --git a/.github/labeler.yml b/.github/labeler.yml index 78366fb2097e..ffe55984ac62 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -240,6 +240,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/device-pair/**" +"extensions: acpx": + - changed-files: + - any-glob-to-any-file: + - "extensions/acpx/**" "extensions: minimax-portal-auth": - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 1502456a2511..faea8807df0e 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -3,6 +3,8 @@ name: Auto response on: issues: types: [opened, edited, labeled] + issue_comment: + types: [created] pull_request_target: types: [labeled] @@ -42,6 +44,7 @@ jobs: { label: "r: testflight", close: true, + commentTriggers: ["testflight"], message: "Not available, build from source.", }, { @@ -55,11 +58,76 @@ jobs: close: true, lock: true, lockReason: "off-topic", + commentTriggers: ["moltbook"], message: "OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.", }, ]; + const maintainerTeam = "maintainer"; + const pingWarningMessage = + "Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd"; + const mentionRegex = /@([A-Za-z0-9-]+)/g; + const maintainerCache = new Map(); + const normalizeLogin = (login) => login.toLowerCase(); + + const isMaintainer = async (login) => { + if (!login) { + return false; + } + const normalized = normalizeLogin(login); + if (maintainerCache.has(normalized)) { + return maintainerCache.get(normalized); + } + let isMember = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: maintainerTeam, + username: normalized, + }); + isMember = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + maintainerCache.set(normalized, isMember); + return isMember; + }; + + const countMaintainerMentions = async (body, authorLogin) => { + if (!body) { + return 0; + } + const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : ""; + if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) { + return 0; + } + + const haystack = body.toLowerCase(); + const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`; + if (haystack.includes(teamMention)) { + return 3; + } + + const mentions = new Set(); + for (const match of body.matchAll(mentionRegex)) { + mentions.add(normalizeLogin(match[1])); + } + if (normalizedAuthor) { + mentions.delete(normalizedAuthor); + } + + let count = 0; + for (const login of mentions) { + if (await isMaintainer(login)) { + count += 1; + } + } + return count; + }; + const triggerLabel = "trigger-response"; const target = context.payload.issue ?? context.payload.pull_request; if (!target) { @@ -72,6 +140,63 @@ jobs: .filter((name) => typeof name === "string"), ); + const issue = context.payload.issue; + const pullRequest = context.payload.pull_request; + const comment = context.payload.comment; + if (comment) { + const authorLogin = comment.user?.login ?? ""; + if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) { + return; + } + + const commentBody = comment.body ?? ""; + const responses = []; + const mentionCount = await countMaintainerMentions(commentBody, authorLogin); + if (mentionCount >= 3) { + responses.push(pingWarningMessage); + } + + const commentHaystack = commentBody.toLowerCase(); + const commentRule = rules.find((item) => + (item.commentTriggers ?? []).some((trigger) => + commentHaystack.includes(trigger), + ), + ); + if (commentRule) { + responses.push(commentRule.message); + } + + if (responses.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + body: responses.join("\n\n"), + }); + } + return; + } + + if (issue) { + const action = context.payload.action; + if (action === "opened" || action === "edited") { + const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim(); + const authorLogin = issue.user?.login ?? ""; + const mentionCount = await countMaintainerMentions( + issueText, + authorLogin, + ); + if (mentionCount >= 3) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: pingWarningMessage, + }); + } + } + } + const hasTriggerLabel = labelSet.has(triggerLabel); if (hasTriggerLabel) { labelSet.delete(triggerLabel); @@ -94,7 +219,6 @@ jobs: return; } - const issue = context.payload.issue; if (issue) { const title = issue.title ?? ""; const body = issue.body ?? ""; @@ -136,7 +260,6 @@ jobs: const noisyPrMessage = "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch."; - const pullRequest = context.payload.pull_request; if (pullRequest) { if (labelSet.has(dirtyLabel)) { await github.rest.issues.createComment({ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abb5b50a5ce8..e7bef285a7ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,9 @@ jobs: - name: Check types and lint and oxfmt run: pnpm check + - name: Enforce safe external URL opening policy + run: pnpm lint:ui:no-raw-window-open + # Report-only dead-code scans. Runs after scope detection and stores machine-readable # results as artifacts for later triage before we enable hard gates. # Temporarily disabled in CI while we process initial findings. @@ -317,6 +320,32 @@ jobs: - name: Check docs run: pnpm check:docs + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python tooling + run: | + python -m pip install --upgrade pip + python -m pip install pytest ruff pyyaml + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + secrets: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: @@ -325,15 +354,20 @@ jobs: with: submodules: false + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install detect-secrets + - name: Install pre-commit run: | python -m pip install --upgrade pip - python -m pip install detect-secrets==1.5.0 + python -m pip install pre-commit detect-secrets==1.5.0 - name: Detect secrets run: | @@ -342,6 +376,30 @@ jobs: exit 1 fi + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + checks-windows: needs: [docs-scope, changed-scope, build-artifacts, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') @@ -360,12 +418,23 @@ jobs: include: - runtime: node task: lint + shard_index: 0 + shard_count: 1 command: pnpm lint - runtime: node task: test + shard_index: 1 + shard_count: 2 + command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: test + shard_index: 2 + shard_count: 2 command: pnpm canvas:a2ui:bundle && pnpm test - runtime: node task: protocol + shard_index: 0 + shard_count: 1 command: pnpm protocol:check steps: - name: Checkout @@ -437,6 +506,12 @@ jobs: pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + - name: Configure test shard (Windows) + if: matrix.task == 'test' + run: | + echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV" + - name: Configure vitest JSON reports if: matrix.task == 'test' run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" @@ -454,7 +529,7 @@ jobs: if: matrix.task == 'test' uses: actions/upload-artifact@v4 with: - name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }} + name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }} path: | ${{ env.OPENCLAW_VITEST_REPORT_DIR }} ${{ runner.temp }}/vitest-slowest.md diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index fc0d97d40913..eff0993b4664 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -172,6 +172,9 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + tags+=("${IMAGE}:latest") + fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No manifest tags resolved for ref ${GITHUB_REF}" diff --git a/.gitignore b/.gitignore index 6b15453504a7..b5d3257e7e61 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ __pycache__/ ui/src/ui/__screenshots__/ ui/playwright-report/ ui/test-results/ +packages/dashboard-next/.next/ +packages/dashboard-next/out/ # Mise configuration files mise.toml @@ -96,6 +98,25 @@ package-lock.json .agents/ .agents .agent/ +skills-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig + +# Xcode build directories (xcodebuild output) +apps/ios/build/ +apps/shared/OpenClawKit/build/ +Swabble/build/ + +# Generated protocol schema (produced via pnpm protocol:gen) +dist/protocol.schema.json +.ant-colony/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..9190f88b6e08 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> diff --git a/.npmrc b/.npmrc index f0c783cb6c87..056200616117 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs +# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 445d62b7efb5..0a928d5f9bae 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -11,12 +11,14 @@ "ignorePatterns": [ "apps/", "assets/", + "CLAUDE.md", "docker-compose.yml", "dist/", "docs/_layouts/", "node_modules/", "patches/", "pnpm-lock.yaml/", + "src/gateway/server-methods/CLAUDE.md", "src/auto-reply/reply/export-html/", "Swabble/", "vendor/", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e946d18c1127..30b6363a34da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,8 @@ repos: - id: check-added-large-files args: [--maxkb=500] - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' # Secret detection (same as CI) - repo: https://github.com/Yelp/detect-secrets @@ -45,7 +47,6 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' - # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 @@ -69,9 +70,34 @@ repos: args: [--persona=regular, --min-severity=medium, --min-confidence=medium] exclude: "^(vendor/|Swabble/)" + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + # Project checks (same commands as CI) - repo: local hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + # oxlint --type-aware src test - id: oxlint name: oxlint diff --git a/AGENTS.md b/AGENTS.md index 3555ef17936b..a0eca7231703 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,11 @@ # Repository Guidelines - Repo: https://github.com/openclaw/openclaw +- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`. - GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". +- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. +- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). +- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. ## Project Structure & Module Organization @@ -83,6 +87,7 @@ - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. - beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. - dev: moving head on `main` (no tag; git checkout main). ## Testing Guidelines @@ -91,6 +96,7 @@ - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. - Do not set test workers above 16; tried already. +- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Full kit + what’s covered: `docs/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). @@ -116,6 +122,15 @@ - If `git branch -d/-D ` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/`. - Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query. +## GitHub Search (`gh`) + +- Prefer targeted keyword search before proposing new work or duplicating fixes. +- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads. +- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Structured output example: + `gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'` + ## Security & Configuration Tips - Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out. @@ -193,6 +208,7 @@ - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`. - For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. +- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. ## NPM + 1Password (publish/verify) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125711ecbd6c..00a409b39c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,40 +2,600 @@ Docs: https://docs.openclaw.ai -## 2026.2.22 (Unreleased) +## 2026.2.26 (Unreleased) ### Changes -- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. -- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. +- Highlight: External Secrets Management introduces a full `openclaw secrets` workflow (`audit`, `configure`, `apply`, `reload`) with runtime snapshot activation, strict `secrets apply` target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant. +- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz. +- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras. +- Codex/WebSocket transport: make `openai-codex` WebSocket-first by default (`transport: "auto"` with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection. +- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras. +- Android/Nodes: add Android `device` capability plus `device.status` and `device.info` node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus. +- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus. +- Docs/Contributing: add Nimrod Gutman to the maintainer roster in `CONTRIBUTING.md`. (#27840) Thanks @ngutman. + +### Fixes + +- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber. +- Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo. +- Google Chat/Lifecycle: keep Google Chat `startAccount` pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy. +- Temp dirs/Linux umask: force `0700` permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so `umask 0002` installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky. +- Nextcloud Talk/Lifecycle: keep `startAccount` pending until abort and stop the webhook monitor on shutdown, preventing `EADDRINUSE` restart loops when the gateway manages account lifecycle. (#27897) +- Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011. +- Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427) +- Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin. +- Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim. +- Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540. +- Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber. +- Telegram/Webhook startup: clarify webhook config guidance, allow `channels.telegram.webhookPort: 0` for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo. +- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553) +- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688) +- Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11. +- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker. +- Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03. +- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674) +- Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra. +- Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602) +- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting. +- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492. +- NO_REPLY suppression: suppress `NO_REPLY` before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531) +- Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (`BodyForAgent`) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd. +- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim. +- Auto-reply/Inbound metadata: add a readable `timestamp` field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy. +- Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding `triggerTyping()` with `runComplete`, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber. +- Typing/Dispatch idle: force typing cleanup when `markDispatchIdle` never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493) +- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy. +- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673. +- Browser/Extension relay CORS: handle `/json*` `OPTIONS` preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842) +- Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928) +- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng. +- Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV. +- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev. +- Feishu/Inbound message metadata: include inbound `message_id` in `BodyForAgent` on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263. +- Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725. +- LINE/Inline directives auth: gate directive parsing (`/model`, `/think`, `/verbose`, `/reasoning`, `/queue`) on resolved authorization (`command.isAuthorizedSender`) so `commands.allowFrom`-authorized LINE senders are not silently stripped when raw `CommandAuthorized` is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240) +- Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky. +- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego. +- CLI/Gateway `--force` in non-root Docker: recover from `lsof` permission failures (`EACCES`/`EPERM`) by falling back to `fuser` kill + probe-based port checks, so `openclaw gateway --force` works for default container `node` user flows. (#27941) +- Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne. +- Sessions cleanup/Doctor: add `openclaw sessions cleanup --fix-missing` to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422) +- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras. +- CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80. +- CLI/Gateway auth: align `gateway run --auth` parsing/help text with supported gateway auth modes by accepting `none` and `trusted-proxy` (in addition to `token`/`password`) for CLI overrides. (#27469) thanks @s1korrrr. +- CLI/Daemon status TLS probe: use `wss://` and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so `openclaw daemon status` works with `gateway.bind=lan` + `gateway.tls.enabled=true`. (#24234) thanks @liuy. +- Podman/Default bind: change `run-openclaw-podman.sh` default gateway bind from `lan` to `loopback` and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla. +- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. +- Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before `/restart` launchctl/systemctl triggers, and set LaunchAgent `ThrottleInterval=60` to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736) +- Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303) +- Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker. +- Models/Profile suffix parsing: centralize trailing `@profile` parsing and only treat `@` as a profile separator when it appears after the final `/`, preserving model IDs like `openai/@cf/...` and `openrouter/@preset/...` across `/model` directive parsing and allowlist model resolution, with regression coverage. +- Models/OpenAI Codex config schema parity: accept `openai-codex-responses` in the config model API schema and TypeScript `ModelApi` union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli. +- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin. +- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497) +- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. +- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting. +- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. +- Security/Node exec approvals hardening: freeze immutable approval-time execution plans (`argv`/`cwd`/`agentId`/`sessionKey`) via `system.run.prepare`, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts. +- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation. +- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455) +- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456) +- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng. +- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. +- Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494) +- Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) +- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. +- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. +- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. + +## 2026.2.25 + +### Changes + +- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus. +- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz. +- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. +- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. +- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. +- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. +- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. + +### Breaking + +- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override). + +### Fixes + +- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. +- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) +- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. +- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. +- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. +- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018) +- LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin. +- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr. +- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. +- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. +- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042) +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. +- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. +- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. +- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. +- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. +- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025) +- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) +- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. +- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. +- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. +- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. +- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. +- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.26`). Thanks @luz-oasis for reporting. +- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.26`). Thanks @zdi-disclosures for reporting. +- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. +- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. +- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3. +- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. +- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. +- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. +- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. +- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. +- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. +- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. + +## 2026.2.24 + +### Changes + +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. +- Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model. +- Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces. +- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). +- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. + +### Breaking + +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. + +### Fixes + +- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. +- Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) +- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. +- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. +- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. +- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. +- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. +- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. +- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) +- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. +- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) +- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. +- Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. +- macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. +- macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. +- macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. +- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. +- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) +- Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. +- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. +- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. +- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. +- Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. +- Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. +- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. +- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting. +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. + +## 2026.2.23 + +### Changes + +- Providers/Kilo Gateway: add first-class `kilocode` provider support (auth, onboarding, implicit provider detection, model defaults, transcript/cache-ttl handling, and docs), with default model `kilocode/anthropic/claude-opus-4.6`. (#20212) Thanks @jrf0110 and @markijbema. +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. +- Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. +- Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. +- Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. + +### Breaking + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + +### Fixes + +- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. +- Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) +- Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. +- Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. +- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. +- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. +- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. +- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Auto-reply/Inbound metadata: move dynamic inbound `flags` (reply/forward/thread/history) from system metadata to user-context conversation info, preventing turn-by-turn prompt-cache invalidation from flag toggles. (#21785) Thanks @aidiffuser. +- Auto-reply/Sessions: remove auth-key labels from `/new` and `/reset` confirmation messages so session reset notices never expose API key prefixes or env-key labels in chat output. (#24384, #24409) Thanks @Clawborn. +- Slack/Group policy: move Slack account `groupPolicy` defaulting to provider-level schema defaults so multi-account configs inherit top-level `channels.slack.groupPolicy` instead of silently overriding inheritance with per-account `allowlist`. (#17579) Thanks @ZetiMente. +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. +- Providers/DashScope: mark DashScope-compatible `openai-completions` endpoints as `supportsDeveloperRole=false` so OpenClaw sends `system` instead of unsupported `developer` role on Qwen/DashScope APIs. (#19130) Thanks @Putzhuawa and @vincentkoc. +- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope. +- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc. +- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. +- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. +- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. +- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) +- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. Thanks @nedlir for reporting. +- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. +- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. + +## 2026.2.22 + +### Changes + +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. +- Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. +- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. +- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. +- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen. +- Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012) - iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman. +- Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc. +- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc. +- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. +- Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc. +- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. +- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. +- Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints. +- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests. +- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. +- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. ### Breaking +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. +- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. ### Fixes +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. +- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. +- Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) +- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. +- Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. +- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) +- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle. +- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. +- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. +- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations. +- Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux. +- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle. +- Webchat/Performance: reload `chat.history` after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz. +- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. +- Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer. +- Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Gateway/Chat UI: sanitize non-streaming final `chat.send`/`chat.inject` payload text with the same envelope/untrusted-context stripping used by `chat.history`, preventing `<<>>` wrapper markup from rendering in Control UI chat. (#24012) Thanks @mittelaltergouda. +- Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message. +- Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops. +- Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions. +- Telegram/Polling: clear Telegram webhooks (`deleteWebhook`) before starting long-poll `getUpdates`, including retry handling for transient cleanup failures. +- Telegram/Webhook: add `channels.telegram.webhookPort` config support and pass it through plugin startup wiring to the monitor listener. +- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807) +- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688) +- Browser/Remote CDP: extend stale-target recovery so `ensureTabAvailable()` now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict `tab not found` errors when multiple tabs exist; includes remote-profile regression tests. (#15989) +- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. +- Gateway/Pairing: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber. +- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. +- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. +- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. +- Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001. +- Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to `failed/` instead of retrying on every restart. (#23794) Thanks @aldoeliacim. +- Cron/Status: split execution outcome (`lastRunStatus`) from delivery outcome (`lastDeliveryStatus`) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors. +- Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto. +- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. +- Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl. +- Cron/Run: persist the manual-run `runningAtMs` marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently. +- Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery. +- Cron/Main session: honor abort/timeout signals while retrying `wakeMode=now` heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window. +- Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber. +- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves. +- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves. +- Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. +- Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. +- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. +- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. +- Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). +- Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) +- Config/Channels: auto-enable built-in channels by writing `channels..enabled=true` (not `plugins.entries.`), and stop adding built-ins to `plugins.allow`, preventing `plugins.entries.telegram: plugin not found` validation failures. +- Config/Channels: when `plugins.allow` is active, auto-enable/enable flows now also allowlist configured built-in channels so `channels..enabled=true` cannot remain blocked by restrictive plugin allowlists. +- Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example `.backup-*`, `.bak`, `.disabled*`) and move updater backup directories under `.openclaw-install-backups`, preventing duplicate plugin-id collisions from archived copies. +- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. +- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. Thanks @jiseoung for reporting. +- Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. Thanks @tdjackey for reporting. +- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. Thanks @jiseoung for reporting. +- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. Thanks @jiseoung for reporting. +- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. Thanks @jiseoung for reporting. +- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. +- Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) +- Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. +- Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) +- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) +- Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) +- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) +- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) +- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting. +- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) +- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931. +- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris. +- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller. +- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design. +- Control UI/WebSocket: send a stable per-tab `instanceId` in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui. +- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. +- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) +- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc. +- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) +- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii. +- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. +- Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed. +- Installer/Smoke tests: remove legacy `OPENCLAW_USE_GUM` overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly. +- Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0. +- Providers/OpenRouter: default reasoning to enabled when the selected model advertises `reasoning: true` and no session/directive override is set. (#22513) Thanks @zwffff. +- Providers/OpenRouter: map `/think` levels to `reasoning.effort` in embedded runs while preserving explicit `reasoning.max_tokens` payloads. (#17236) Thanks @robbyczgw-cla. +- Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, `anthropic/...`) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson. +- Providers/OpenRouter: preserve the required `openrouter/` prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445. +- Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko. +- Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example `openrouter/@preset/...`) by treating `/model ...@profile` auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream. +- Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo. +- Cron/Follow-up: pass resolved `agentDir` through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk. +- Agents/Media: route tool-result `MEDIA:` extraction through shared parser validation so malformed prose like `MEDIA:-prefixed ...` is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM. +- Logging: cap single log-file size with `logging.maxFileBytes` (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms. +- Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (`withRemoteHttpResponse`) so embeddings and batch flows use one request/release path. +- Memory/Embeddings: apply configured remote-base host pinning (`allowedHostnames`) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook. +- Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement. +- Memory/Index: detect memory source-set changes (for example enabling `sessions` after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring `--force`. (#17576) Thanks @TarsAI-Agent. +- Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove. +- Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. +- Memory/QMD: parse plain-text `qmd collection list --json` output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns `Collection not found ...`. (#23613) Thanks @leozhucn. +- iOS/Watch: normalize watch quick-action notification payloads, support mirrored indexed actions beyond primary/secondary, and fix iOS test-target signing/compile blockers for watch notify coverage. (#23636) Thanks @mbelinky. +- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. +- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. +- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. +- Auth/Profiles: keep active `cooldownUntil`/`disabledUntil` windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual `usageStats` cleanup. (#23516, #23536) Thanks @arosstale. +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. +- Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to `wss://`, rejecting insecure non-loopback `ws://` targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3. +- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. +- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. +- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber. +- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728. +- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen. +- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation. +- Gateway/Auth: preserve shared-token `gateway token mismatch` auth errors when `auth.token` fallback device-token checks fail, and reserve `device token mismatch` guidance for explicit `auth.deviceToken` failures. +- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets. +- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures. +- Node host/Exec: keep strict Windows allowlist behavior for `cmd.exe /c` shell-wrapper runs, and return explicit approval guidance when blocked (`SYSTEM_RUN_DENIED: allowlist miss`). +- Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`. +- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). +- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. +- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. +- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. Thanks @tdjackey for reporting. +- Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. Thanks @aether-ai-agent for reporting. +- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. Thanks @tdjackey for reporting. +- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. +- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. Thanks @aether-ai-agent for reporting. +- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. +- Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. +- Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. +- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. +- Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies. +- Telegram/Replies: normalize `file://` and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies. +- Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. +- Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. +- Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. +- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. +- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. +- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc. +- Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12. +- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. +- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. +- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. +- Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728. +- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. +- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. +- Memory/QMD: normalize Han-script BM25 search queries before invoking `qmd search` so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130. +- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. +- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96. +- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. +- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev. +- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. +- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. +- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. +- Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 `HTTP 400` failures on tool continuations. (#23698) Thanks @echoVic. +- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. +- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue. +- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. +- Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. +- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. +- Agents/Auth profiles: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. +- Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. +- Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. +- Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. +- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. +- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. +- Infra/Network: classify undici `TypeError: fetch failed` as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. +- Docs/Telegram: correct Node 22+ network defaults (`autoSelectFamily`, `dnsResultOrder`) and clarify Telegram setup does not use positional `openclaw channels login telegram`. (#23609) Thanks @ryanbastic. +- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic. +- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. +- Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. +- Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. - Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. -- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting. +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. Thanks @tdjackey for reporting. +- Network/SSRF: enable `autoSelectFamily` on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness. - Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. -- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift. +- Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. +- Security/Exec: fail closed when `tools.exec.host=sandbox` is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3. +- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. Thanks @aether-ai-agent for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. Thanks @princeeismond-dot for reporting. +- Security/SSRF: block RFC2544 benchmarking range (`198.18.0.0/15`) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example `::127.0.0.1`, `64:ff9b::8.8.8.8`) in shared IP parsing/classification. - Security/Archive: block zip symlink escapes during archive extraction. - Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. +- Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku. - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. - Security/Gateway: block node-role connections when device identity metadata is missing. -- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. Thanks @tdjackey for reporting. +- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. +- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. Thanks @tdjackey for reporting. +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. Thanks @tdjackey for reporting. +- Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats. +- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting. +- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting. +- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore. - Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. - CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67. - Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways. - Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. - Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. +- Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre. +- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic. - Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. - Gateway/Daemon: verify gateway health after daemon restart. - Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. @@ -70,6 +630,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Bootstrap: skip malformed bootstrap files with missing/invalid paths instead of crashing agent sessions; hooks using `filePath` (or non-string `path`) are skipped with a warning. (#22693, #22698) Thanks @arosstale. - Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). - Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. - Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. @@ -83,6 +644,7 @@ Docs: https://docs.openclaw.ai - Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. - Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728. - Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data. +- Sessions/Usage: persist `totalTokens` from `promptTokens` snapshots even when providers omit structured usage payloads, so session history/status no longer regress to `unknown` token utilization for otherwise successful runs. (#21819) Thanks @zymclaw. - Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. - WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. - Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. @@ -90,6 +652,7 @@ Docs: https://docs.openclaw.ai - CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. - TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. - TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. +- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends. - TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. - TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff. - Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. @@ -99,6 +662,7 @@ Docs: https://docs.openclaw.ai - Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. - Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012. - CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet. +- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset ` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick. - CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet. - Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo. - Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. @@ -198,6 +762,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security: strip hidden text from `web_fetch` extracted content to prevent indirect prompt injection, covering CSS-hidden elements, class-based hiding (sr-only, d-none, etc.), invisible Unicode, color:transparent, offscreen transforms, and non-content tags. (#8027, #21074) Thanks @hydro13 for the fix and @LucasAIBuilder for reporting. - Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. - iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. - iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. @@ -207,8 +772,10 @@ Docs: https://docs.openclaw.ai - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. - iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Telegram: keep media-group processing resilient by skipping recoverable per-item download failures while still failing loud on non-recoverable media errors. (#20598) thanks @mcaxtr. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. +- Telegram/DM routing: prevent DM inbound origin metadata from leaking into main-session `lastRoute` updates and normalize DM `lastRoute.to` to provider-prefixed `telegram:`. (#19491) thanks @guirguispierre. - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. - Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. - Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor. diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553f..47dc3e3d863c 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2beaeeba290e..020857354567 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,15 @@ Welcome to the lobster tank! 🦞 - **Mariano Belinky** - iOS app, Security - GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad) +- **Nimrod Gutman** - iOS app, macOS app and crustacean features + - GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti) + - **Vincent Koc** - Agents, Telemetry, Hooks, Security - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + - **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) @@ -47,11 +53,17 @@ Welcome to the lobster tank! 🦞 - **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams - GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz) +- **Josh Avant** - Core, CLI, Gateway, Security, Agents + - GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant) + +- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT + - Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR diff --git a/Dockerfile b/Dockerfile index 255340cb02bf..2229a299a560 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,9 @@ COPY --chown=node:node patches ./patches COPY --chown=node:node scripts ./scripts USER node -RUN pnpm install --frozen-lockfile +# Reduce OOM risk on low-memory hosts during dependency installation. +# Docker builds on small VMs may otherwise fail with "Killed" (exit 137). +RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... diff --git a/README.md b/README.md index 72f362418d72..1dcad2b7e125 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. @@ -38,7 +38,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). @@ -146,13 +145,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). - [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). - [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. -- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups). +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). - [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). ### Channels - [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). -- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes @@ -171,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Runtime + safety -- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). - [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). - [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). - [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting). @@ -503,78 +502,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

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

diff --git a/SECURITY.md b/SECURITY.md index 4b51daeaa73f..d7e4977e6000 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives: - **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) - **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. +For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it. For full reporting instructions see our [Trust page](https://trust.openclaw.ai). @@ -30,6 +30,44 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai). Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). +- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. +- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +These are frequently reported but are typically closed with no code change: + +- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). +- Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. +- Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries. +- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. +- Missing HSTS findings on default local/loopback deployments. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + ## Security & Trust **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. @@ -43,12 +81,45 @@ The best way to help the project right now is by sending PRs. When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. +## Operator Trust Model (Important) + +OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. + +- Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. +- If one operator can view data from another operator on the same gateway, that is expected in this trust model. +- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. +- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway. +- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user. +- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default. +- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`. +- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host. +- Implicit exec calls (no explicit host in the tool call) follow the same behavior. +- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. + +## Trusted Plugin Concept (Core) + +Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. + +- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host. +- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary. +- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin. + ## Out of Scope - Public Internet Exposure - Using OpenClaw in ways that the docs recommend not to -- Deployments where mutually untrusted/adversarial operators share one gateway host and config -- Prompt injection attacks +- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) +- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) +- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) +- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). +- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) +- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. +- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening. +- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact +- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass. +- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow. ## Deployment Assumptions @@ -57,6 +128,44 @@ OpenClaw security guidance assumes: - The host where OpenClaw runs is within a trusted OS/admin boundary. - Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. +- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. +- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user). + +## One-User Trust Model (Personal Assistant) + +OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." + +- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. +- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. +- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. +- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. +- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk. + +## Agent and Model Assumptions + +- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. +- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals. +- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. + +## Gateway and Node trust concept + +OpenClaw separates routing from execution, but both remain inside the same operator trust boundary: + +- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway. +- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node. +- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary. +- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass. +- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary. + +## Workspace Memory Trust Boundary + +`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state. + +- If someone can edit workspace memory files, they already crossed the trusted operator boundary. +- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary. +- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it." +- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways. ## Plugin Trust Boundary @@ -66,6 +175,23 @@ Plugins/extensions are loaded **in-process** with the Gateway and are treated as - Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. - Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. +## Temp Folder Boundary (Media/Sandbox) + +OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts: + +- Preferred temp root: `/tmp/openclaw` (when available and safe on the host). +- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-` on multi-user hosts). + +Security boundary notes: + +- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. +- Arbitrary host tmp paths are not treated as trusted media roots. +- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. +- Enforcement reference points: + - temp root resolver: `src/infra/tmp-openclaw-dir.ts` + - SDK temp helpers: `src/plugin-sdk/temp-path.ts` + - messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs` + ## Operational Guidance For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: @@ -75,7 +201,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep ### Tool filesystem hardening - `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory. +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory. - Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. ### Web Interface Safety @@ -85,6 +211,10 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for * - Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`). - Config: `gateway.bind="loopback"` (default). - CLI: `openclaw gateway run --bind loopback`. +- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use. + - OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups. + - Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings. + - This operator-selected tradeoff is by design and not, by itself, a security vulnerability. - Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet). - Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls. - Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`. diff --git a/appcast.xml b/appcast.xml index ac9369da0076..f5eb16999343 100644 --- a/appcast.xml +++ b/appcast.xml @@ -209,155 +209,84 @@ - 2026.2.21 - Sat, 21 Feb 2026 17:55:48 +0100 + 2026.2.25 + Thu, 26 Feb 2026 05:14:17 +0100 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 13056 - 2026.2.21 + 14883 + 2026.2.25 15.0 - OpenClaw 2026.2.21 + OpenClaw 2026.2.25

Changes

    -
  • Models/Google: add Gemini 3.1 support (google/gemini-3.1-pro-preview).
  • -
  • Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to volcengine-api-key. (#7967) Thanks @funmore123.
  • -
  • Channels/CLI: add per-account/channel defaultTo outbound routing fallback so openclaw agent --deliver can send without explicit --reply-to when a default target is configured. (#16985) Thanks @KirillShchetinin.
  • -
  • Channels: allow per-channel model overrides via channels.modelByChannel and note them in /status. Thanks @thewilloftheshadow.
  • -
  • Telegram/Streaming: simplify preview streaming config to channels.telegram.streaming (boolean), auto-map legacy streamMode values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.
  • -
  • Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
  • -
  • Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
  • -
  • Discord/Voice: add voice channel join/leave/status via /vc, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
  • -
  • Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.
  • -
  • Discord: support updating forum available_tags via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.
  • -
  • Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
  • -
  • Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.
  • -
  • iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
  • -
  • iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
  • -
  • iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
  • -
  • Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
  • -
  • MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.
  • -
  • Agents/Subagents: default subagent spawn depth now uses shared maxSpawnDepth=2, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
  • -
  • Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (ownerDisplaySecret) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.
  • -
  • Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.
  • -
  • Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.
  • -
  • Dependencies/Unused Dependencies: remove or scope unused root and extension deps (@larksuiteoapi/node-sdk, signal-utils, ollama, lit, @lit/context, @lit-labs/signals, @microsoft/agents-hosting-express, @microsoft/agents-hosting-extensions-teams, and plugin-local openclaw devDeps in extensions/open-prose, extensions/lobster, and extensions/llm-task). (#22471, #22495) Thanks @vincentkoc.
  • -
  • Dependencies/A2UI: harden dependency resolution after root cleanup (resolve lit, @lit/context, @lit-labs/signals, and signal-utils from workspace/root) and simplify bundling fallback behavior, including pnpm dlx rolldown compatibility. (#22481, #22507) Thanks @vincentkoc.
  • +
  • Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
  • +
  • Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
  • +
  • UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
  • +
  • Heartbeat/Config: replace heartbeat DM toggle with agents.defaults.heartbeat.directPolicy (allow | block; also supported per-agent via agents.list[].heartbeat.directPolicy) for clearer delivery semantics.
  • +
  • Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
  • +
  • Branding/Docs + Apple surfaces: replace remaining bot.molt launchd label, bundle-id, logging subsystem, and command examples with ai.openclaw across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
  • +
  • Agents/Config: remind agents to call config.schema before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
  • +
  • Dependencies: update workspace dependency pins and lockfile (Bedrock SDK 3.998.0, @mariozechner/pi-* 0.55.1, TypeScript native preview 7.0.0-dev.20260225.1) while keeping @buape/carbon pinned.
  • +
+

Breaking

+
    +
  • BREAKING: Heartbeat direct/DM delivery default is now allow again. To keep DM-blocked behavior from 2026.2.24, set agents.defaults.heartbeat.directPolicy: "block" (or per-agent override).

Fixes

    -
  • Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit retry_limit error payload when retries never converge, preventing unbounded internal retry cycles (GHSA-76m6-pj3w-v7mf).
  • -
  • Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless getUpdates conflict loops.
  • -
  • Agents/Tool images: include source filenames in agents/tool-images resize logs so compression events can be traced back to specific files.
  • -
  • Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.
  • -
  • Models/Kimi-Coding: add missing implicit provider template for kimi-coding with correct anthropic-messages API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)
  • -
  • Auto-reply/Tools: forward senderIsOwner through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.
  • -
  • Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.
  • -
  • Memory/QMD: respect per-agent memorySearch.enabled=false during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (search/vsearch/query) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip qmd embed in BM25-only search mode (including memory index --force), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.
  • -
  • Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so onSearch/onSessionStart no longer fail with database is not open in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.
  • -
  • Providers/Copilot: drop persisted assistant thinking blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid thinkingSignature payloads. (#19459) Thanks @jackheuberger.
  • -
  • Providers/Copilot: add claude-sonnet-4.6 and claude-sonnet-4.5 to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.
  • -
  • Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example whatsapp) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.
  • -
  • Status: include persisted cacheRead/cacheWrite in session summaries so compact /status output consistently shows cache hit percentages from real session data.
  • -
  • Heartbeat/Cron: restore interval heartbeat behavior so missing HEARTBEAT.md no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.
  • -
  • WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured allowFrom recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
  • -
  • Heartbeat/Active hours: constrain active-hours 24 sentinel parsing to 24:00 in time validation so invalid values like 24:30 are rejected early. (#21410) thanks @adhitShet.
  • -
  • Heartbeat: treat activeHours windows with identical start/end times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
  • -
  • CLI/Pairing: default pairing list and pairing approve to the sole available pairing channel when omitted, so TUI-only setups can recover from pairing required without guessing channel arguments. (#21527) Thanks @losts1.
  • -
  • TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return pairing required, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.
  • -
  • TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.
  • -
  • TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when showOk is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.
  • -
  • TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with RangeError: Maximum call stack size exceeded. (#18068) Thanks @JaniJegoroff.
  • -
  • Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.
  • -
  • Memory/Tools: return explicit unavailable warnings/actions from memory_search when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.
  • -
  • Session/Startup: require the /new and /reset greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.
  • -
  • Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing provider:default mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.
  • -
  • Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
  • -
  • Slack: pass recipient_team_id / recipient_user_id through Slack native streaming calls so chat.startStream/appendStream/stopStream work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
  • -
  • CLI/Config: add canonical --strict-json parsing for config set and keep --json as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.
  • -
  • CLI: keep openclaw -v as a root-only version alias so subcommand -v, --verbose flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.
  • -
  • Memory: return empty snippets when memory_get/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.
  • -
  • Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
  • -
  • Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.
  • -
  • Telegram/Streaming: restore 30-char first-preview debounce and scope NO_REPLY prefix suppression to partial sentinel fragments so normal No... text is not filtered. (#22613) thanks @obviyus.
  • -
  • Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.
  • -
  • Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.
  • -
  • Discord/Streaming: apply replyToMode: first only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.
  • -
  • Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.
  • -
  • Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.
  • -
  • Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.
  • -
  • Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.
  • -
  • Auto-reply/Runner: emit onAgentRunStart only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.
  • -
  • Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.
  • -
  • Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (message_id, message_id_full, reply_to_id, sender_id) into untrusted conversation context. (#20597) Thanks @anisoptera.
  • -
  • iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.
  • -
  • iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.
  • -
  • CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate /v1 paths during setup checks. (#21336) Thanks @17jmumford.
  • -
  • iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable nodes invoke pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.
  • -
  • Gateway/Auth: require gateway.trustedProxies to include a loopback proxy address when auth.mode="trusted-proxy" and bind="loopback", preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.
  • -
  • Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured gateway.trustedProxies. (#20097) thanks @xinhuagu.
  • -
  • Gateway/Auth: allow authenticated clients across roles/scopes to call health while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.
  • -
  • Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.
  • -
  • Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.
  • -
  • Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.
  • -
  • Gateway/Pairing: clear persisted paired-device state when the gateway client closes with device token mismatch (1008) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.
  • -
  • Gateway/Config: allow gateway.customBindHost in strict config validation when gateway.bind="custom" so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
  • -
  • Gateway/Pairing: tolerate legacy paired devices missing roles/scopes metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
  • -
  • Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local openclaw devices fallback recovery for loopback pairing required deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
  • -
  • Cron: honor cron.maxConcurrentRuns in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
  • -
  • Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
  • -
  • Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
  • -
  • Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
  • -
  • Agents/Tool display: fix exec cwd suffix inference so pushd ... && popd ... && does not keep stale (in ) context in summaries. (#21925) Thanks @Lukavyi.
  • -
  • Tools/web_search: handle xAI Responses API payloads that emit top-level output_text blocks (without a message wrapper) so Grok web_search no longer returns No response for those results. (#20508) Thanks @echoVic.
  • -
  • Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.
  • -
  • Docker/Build: include ownerDisplay in CommandsSchema object-level defaults so Docker pnpm build no longer fails with TS2769 during plugin SDK d.ts generation. (#22558) Thanks @obviyus.
  • -
  • Docker/Browser: install Playwright Chromium into /home/node/.cache/ms-playwright and set node:node ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.
  • -
  • Hooks/Session memory: trigger bundled session-memory persistence on both /new and /reset so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.
  • -
  • Dependencies/Agents: bump embedded Pi SDK packages (@mariozechner/pi-agent-core, @mariozechner/pi-ai, @mariozechner/pi-coding-agent, @mariozechner/pi-tui) to 0.54.0. (#21578) Thanks @Takhoffman.
  • -
  • Config/Agents: expose Pi compaction tuning values agents.defaults.compaction.reserveTokens and agents.defaults.compaction.keepRecentTokens in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via reserveTokensFloor. (#21568) Thanks @Takhoffman.
  • -
  • Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
  • -
  • Docker: run build steps as the node user and use COPY --chown to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.
  • -
  • Config/Memory: restore schema help/label metadata for hybrid mmr and temporalDecay settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.
  • -
  • Skills/SonosCLI: add troubleshooting guidance for sonos discover failures on macOS direct mode (sendto: no route to host) and sandbox network restrictions (bind: operation not permitted). (#21316) Thanks @huntharo.
  • -
  • macOS/Build: default release packaging to BUNDLE_ID=ai.openclaw.mac in scripts/package-mac-dist.sh, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
  • -
  • Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
  • -
  • Anthropic/Agents: preserve required pi-ai default OAuth beta headers when context1m injects anthropic-beta, preventing 401 auth failures for sk-ant-oat-* tokens. (#19789, fixes #19769) Thanks @minupla.
  • -
  • Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.
  • -
  • macOS/Security: evaluate system.run allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via rawCommand chaining. Thanks @tdjackey for reporting.
  • -
  • WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging chatJid + valid messageId pairs. Thanks @aether-ai-agent for reporting.
  • -
  • ACP/Security: escape control and delimiter characters in ACP resource_link title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.
  • -
  • TTS/Security: make model-driven provider switching opt-in by default (messages.tts.modelOverrides.allowProvider=false unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.
  • -
  • Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.
  • -
  • BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.
  • -
  • iOS/Security: force https:// for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
  • -
  • Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.
  • -
  • Gateway/Security: require secure context and paired-device checks for Control UI auth even when gateway.controlUi.allowInsecureAuth is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.
  • -
  • Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.
  • -
  • Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.
  • -
  • Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.
  • -
  • Security/Commands: block prototype-key injection in runtime /debug overrides and require own-property checks for gated command flags (bash, config, debug) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.
  • -
  • Security/Browser: block non-network browser navigation protocols (including file:, data:, and javascript:) while preserving about:blank, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.
  • -
  • Security/Exec: block shell startup-file env injection (BASH_ENV, ENV, BASH_FUNC_*, LD_*, DYLD_*) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.
  • -
  • Security/Exec (Windows): canonicalize cmd.exe /c command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in system.run. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway/Hooks: block __proto__, constructor, and prototype traversal in webhook template path resolution to prevent prototype-chain payload data leakage in messageTemplate rendering. (#22213) Thanks @SleuthCo.
  • -
  • Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.
  • -
  • Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.
  • -
  • Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.
  • -
  • Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.
  • -
  • Security/Net: strip sensitive headers (Authorization, Proxy-Authorization, Cookie, Cookie2) on cross-origin redirects in fetchWithSsrFGuard to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.
  • -
  • Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.
  • -
  • Security/Tools: add per-wrapper random IDs to untrusted-content markers from wrapExternalContent/wrapWebContent, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.
  • -
  • Shared/Security: reject insecure deep links that use ws:// non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
  • -
  • macOS/Security: reject non-loopback ws:// remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
  • -
  • Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
  • -
  • Security/Dependencies: bump transitive hono usage to 4.11.10 to incorporate timing-safe authentication comparison hardening for basicAuth/bearerAuth (GHSA-gq3j-xvxp-8hrf). Thanks @vincentkoc.
  • -
  • Security/Gateway: parse X-Forwarded-For with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.
  • -
  • Security/Sandbox: remove default --no-sandbox for the browser container entrypoint, add explicit opt-in via OPENCLAW_BROWSER_NO_SANDBOX / CLAWDBOT_BROWSER_NO_SANDBOX, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.
  • -
  • Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.
  • -
  • Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (openclaw-sandbox-browser), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in openclaw security --audit when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.
  • +
  • Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without message_id as delivery failures (instead of false-success "unknown" IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
  • +
  • Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
  • +
  • Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable session.parentForkMaxTokens (default 100000, 0 disables). (#26912) Thanks @markshields-tl.
  • +
  • Cron/Message multi-account routing: honor explicit delivery.accountId for isolated cron delivery resolution, and when message.send omits accountId, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
  • +
  • Gateway/Message media roots: thread agentId through gateway send RPC and prefer explicit agentId over session/default resolution so non-default agent workspace media sends no longer fail with LocalMediaAccessError; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
  • +
  • Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.
  • +
  • Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed delivered, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
  • +
  • LINE/Lifecycle: keep LINE startAccount pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
  • +
  • Discord/Gateway: capture and drain startup-time gateway error events before lifecycle listeners attach so early Fatal Gateway error: 4014 closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
  • +
  • Discord/Inbound text: preserve embed title + description fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
  • +
  • Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to file so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
  • +
  • Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
  • +
  • Telegram/Markdown spoilers: keep valid ||spoiler|| pairs while leaving unmatched trailing || delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
  • +
  • Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example c0abc12345) correctly match Slack runtime IDs (C0ABC12345) under groupPolicy: "allowlist", preventing silent channel-event drops. (#26878) Thanks @lbo728.
  • +
  • Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
  • +
  • Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
  • +
  • Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including NO_REPLY, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
  • +
  • Voice-call/TTS tools: hide the tts tool when the message provider is voice, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
  • +
  • Agents/Tools: normalize non-standard plugin tool results that omit content so embedded runs no longer crash with Cannot read properties of undefined (reading 'filter') after tool completion (including tesseramemo_query). (#27007)
  • +
  • Cron/Model overrides: when isolated payload.model is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
  • +
  • Agents/Model fallback: keep explicit text + image fallback chains reachable even when agents.defaults.models allowlists are present, prefer explicit run agentId over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify model_cooldown / cooling down errors as rate_limit so failover continues. (#11972, #24137, #17231)
  • +
  • Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of disabledReason only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for rate_limit. (#23816) thanks @ramezgaberiel.
  • +
  • Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
  • +
  • Models/Auth probes: map permanent auth failover reasons (auth_permanent, for example revoked keys) into probe auth status instead of unknown, so openclaw models status --probe reports actionable auth failures. (#25754) thanks @rrenamed.
  • +
  • Hooks/Inbound metadata: include guildId and channelName in message_received metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
  • +
  • Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get CommandAuthorized: true on modal/button events. (#26119) Thanks @bmendonca3.
  • +
  • Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
  • +
  • Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (2026.2.25). Thanks @luz-oasis for reporting.
  • +
  • Security/Gateway trusted proxy: require operator role for the Control UI trusted-proxy pairing bypass so unpaired node sessions can no longer connect via client.id=control-ui and invoke node event methods. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy oauth.json onboarding path that exposed the PKCE verifier via OAuth state; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (2026.2.25). Thanks @zdi-disclosures for reporting.
  • +
  • Security/Microsoft Teams file consent: bind fileConsent/invoke upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked uploadId values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Gateway: harden agents.files path handling to block out-of-workspace symlink targets for agents.files.get/agents.files.set, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
  • +
  • Security/Workspace FS: reject hardlinked workspace file aliases in tools.fs.workspaceOnly and tools.exec.applyPatch.workspaceOnly boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before setFiles, with regression coverage for strict missing-path handling.
  • +
  • Security/Exec approvals: bind system.run approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: harden approval-bound system.run execution on node hosts by rejecting symlink cwd paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under dmPolicy/groupPolicy; reaction notifications now require channel access checks first. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild groupPolicy channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Slack reactions + pins: gate reaction_* and pin_* system-event enqueue through shared sender authorization so DM dmPolicy/allowFrom and channel users allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Telegram reactions: enforce dmPolicy/allowFrom and group allowlist authorization on message_reaction events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Slack interactions: enforce channel/DM authorization and modal actor binding (private_metadata.userId) before enqueueing block_action/view_submission/view_closed system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
  • +
  • Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
  • +
  • Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
  • +
  • Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
  • +
  • Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
  • +
  • Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
  • +
  • Security/SSRF guard: classify IPv6 multicast literals (ff00::/8) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (2026.2.25). Thanks @zpbrent for reporting.
  • +
  • Tests/Low-memory stability: disable Vitest vmForks by default on low-memory local hosts (<64 GiB), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with setSessionRuntimeModel usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.

View full changelog

]]>
- +
\ No newline at end of file diff --git a/apps/android/README.md b/apps/android/README.md index c2ae5a2179bf..4a9951e64412 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -1,13 +1,26 @@ -## OpenClaw Node (Android) (internal) +## OpenClaw Android App -Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**. +Status: **extremely alpha**. The app is actively being rebuilt from the ground up. -Notes: -- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). -- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android). -- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose). +### Rebuild Checklist + +- [x] New 4-step onboarding flow +- [x] Connect tab with `Setup Code` + `Manual` modes +- [x] Encrypted persistence for gateway setup/auth state +- [x] Chat UI restyled +- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect) +- [ ] QR code scanning in onboarding +- [ ] Performance improvements +- [ ] Streaming support in chat UI +- [ ] Request camera/location and other permissions in onboarding/settings flow +- [ ] Push notifications for gateway/chat status updates +- [ ] Security hardening (biometric lock, token handling, safer defaults) +- [ ] Voice tab full functionality +- [ ] Screen tab full functionality +- [ ] Full end-to-end QA and release hardening ## Open in Android Studio + - Open the folder `apps/android`. ## Build / Run @@ -21,18 +34,105 @@ cd apps/android `gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. +## Macrobenchmark (Startup + Frame Timing) + +```bash +cd apps/android +./gradlew :benchmark:connectedDebugAndroidTest +``` + +Reports are written under: + +- `apps/android/benchmark/build/reports/androidTests/connected/` + +## Perf CLI (low-noise) + +Deterministic startup measurement + hotspot extraction with compact CLI output: + +```bash +cd apps/android +./scripts/perf-startup-benchmark.sh +./scripts/perf-startup-hotspots.sh +``` + +Benchmark script behavior: + +- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations). +- Prints median/min/max/COV in one line. +- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`. +- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline `). + +Hotspot script behavior: + +- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`. +- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView). +- Writes raw `perf.data` path for deeper follow-up if needed. + +## Run on a Real Android Phone (USB) + +1) On phone, enable **Developer options** + **USB debugging**. +2) Connect by USB and accept the debugging trust prompt on phone. +3) Verify ADB can see the device: + +```bash +adb devices -l +``` + +4) Install + launch debug build: + +```bash +pnpm android:install +pnpm android:run +``` + +If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again. + +### USB-only gateway testing (no LAN dependency) + +Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`. + +Terminal A (gateway): + +```bash +pnpm openclaw gateway --port 18789 --verbose +``` + +Terminal B (USB tunnel): + +```bash +adb reverse tcp:18789 tcp:18789 +``` + +Then in app **Connect → Manual**: + +- Host: `127.0.0.1` +- Port: `18789` +- TLS: off + +## Hot Reload / Fast Iteration + +This app is native Kotlin + Jetpack Compose. + +- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement). +- For many non-structural code/resource changes: use Android Studio **Apply Changes**. +- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`). +- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`). + ## Connect / Pair -1) Start the gateway (on your “master” machine): +1) Start the gateway (on your main machine): + ```bash pnpm openclaw gateway --port 18789 --verbose ``` 2) In the Android app: -- Open **Settings** -- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port). + +- Open the **Connect** tab. +- Use **Setup Code** or **Manual** mode to connect. 3) Approve pairing (on the gateway machine): + ```bash openclaw nodes pending openclaw nodes approve @@ -49,3 +149,8 @@ More details: `docs/platforms/android.md`. - Camera: - `CAMERA` for `camera.snap` and `camera.clip` - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` + +## Contributions + +This Android app is currently being rebuilt. +Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord. diff --git a/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt new file mode 100644 index 000000000000..472064afc4b8 --- /dev/null +++ b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b91b1e215376..5e9a27d13eb8 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -2,7 +2,6 @@ import com.android.build.api.variant.impl.VariantOutputImpl plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") } @@ -13,7 +12,7 @@ android { sourceSets { getByName("main") { - assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")) + assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") } } @@ -21,8 +20,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602210 - versionName = "2026.2.21" + versionCode = 202602260 + versionName = "2026.2.26" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -97,7 +96,7 @@ kotlin { } dependencies { - val composeBom = platform("androidx.compose:compose-bom:2025.12.00") + val composeBom = platform("androidx.compose:compose-bom:2026.02.00") implementation(composeBom) androidTestImplementation(composeBom) @@ -112,7 +111,7 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.6") + implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -120,12 +119,17 @@ dependencies { implementation("com.google.android.material:material:1.13.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.exifinterface:exifinterface:1.4.2") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("org.bouncycastle:bcprov-jdk18on:1.83") + implementation("org.commonmark:commonmark:0.27.1") + implementation("org.commonmark:commonmark-ext-autolink:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1") + implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1") // CameraX (for node.invoke camera.* parity) implementation("androidx.camera:camera-core:1.5.2") @@ -133,15 +137,17 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.5.2") implementation("androidx.camera:camera-video:1.5.2") implementation("androidx.camera:camera-view:1.5.2") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. implementation("dnsjava:dnsjava:3.6.4") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") - testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") - testImplementation("org.robolectric:robolectric:4.16") + testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3") + testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") + testImplementation("org.robolectric:robolectric:4.16.1") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") } diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index facdbf301b42..3d0b27f39e66 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,15 @@ android:name=".NodeForegroundService" android:exported="false" android:foregroundServiceType="dataSync|microphone|mediaProjection" /> + + + + + diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt index 2bbfd8712f92..b90427672c60 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt @@ -1,20 +1,13 @@ package ai.openclaw.android -import android.Manifest -import android.content.pm.ApplicationInfo import android.os.Bundle -import android.os.Build import android.view.WindowManager -import android.webkit.WebView import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.core.view.WindowCompat import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -29,12 +22,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 - WebView.setWebContentsDebuggingEnabled(isDebuggable) - applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() - NodeForegroundService.start(this) + WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) screenCaptureRequester = ScreenCaptureRequester(this) viewModel.camera.attachLifecycleOwner(this) @@ -62,18 +50,9 @@ class MainActivity : ComponentActivity() { } } } - } - override fun onResume() { - super.onResume() - applyImmersiveMode() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - applyImmersiveMode() - } + // Keep startup path lean: start foreground service after first frame. + window.decorView.post { NodeForegroundService.start(this) } } override fun onStart() { @@ -85,46 +64,4 @@ class MainActivity : ComponentActivity() { viewModel.setForeground(false) super.onStop() } - - private fun applyImmersiveMode() { - WindowCompat.setDecorFitsSystemWindows(window, false) - val controller = WindowInsetsControllerCompat(window, window.decorView) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.hide(WindowInsetsCompat.Type.systemBars()) - } - - private fun requestDiscoveryPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT >= 33) { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) - } - } else { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) - } - } - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) return - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) - } - } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index d9123d10293e..e0d68c77e69c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -8,12 +8,17 @@ import ai.openclaw.android.node.CameraCaptureManager import ai.openclaw.android.node.CanvasController import ai.openclaw.android.node.ScreenRecordManager import ai.openclaw.android.node.SmsManager +import ai.openclaw.android.voice.VoiceConversationEntry import kotlinx.coroutines.flow.StateFlow class MainViewModel(app: Application) : AndroidViewModel(app) { private val runtime: NodeRuntime = (app as NodeApp).runtime val canvas: CanvasController = runtime.canvas + val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl + val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated + val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending + val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText val camera: CameraCaptureManager = runtime.camera val screenRecorder: ScreenRecordManager = runtime.screenRecorder val sms: SmsManager = runtime.sms @@ -22,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val discoveryStatusText: StateFlow = runtime.discoveryStatusText val isConnected: StateFlow = runtime.isConnected + val isNodeConnected: StateFlow = runtime.nodeConnected val statusText: StateFlow = runtime.statusText val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress @@ -40,19 +46,20 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val locationMode: StateFlow = runtime.locationMode val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled val preventSleep: StateFlow = runtime.preventSleep - val wakeWords: StateFlow> = runtime.wakeWords - val voiceWakeMode: StateFlow = runtime.voiceWakeMode - val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText - val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening - val talkEnabled: StateFlow = runtime.talkEnabled - val talkStatusText: StateFlow = runtime.talkStatusText - val talkIsListening: StateFlow = runtime.talkIsListening - val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking + val micEnabled: StateFlow = runtime.micEnabled + val micStatusText: StateFlow = runtime.micStatusText + val micLiveTranscript: StateFlow = runtime.micLiveTranscript + val micIsListening: StateFlow = runtime.micIsListening + val micQueuedMessages: StateFlow> = runtime.micQueuedMessages + val micConversation: StateFlow> = runtime.micConversation + val micInputLevel: StateFlow = runtime.micInputLevel + val micIsSending: StateFlow = runtime.micIsSending val manualEnabled: StateFlow = runtime.manualEnabled val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort val manualTls: StateFlow = runtime.manualTls val gatewayToken: StateFlow = runtime.gatewayToken + val onboardingCompleted: StateFlow = runtime.onboardingCompleted val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled val chatSessionKey: StateFlow = runtime.chatSessionKey @@ -110,24 +117,20 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setGatewayToken(value) } - fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) - } - - fun setWakeWords(words: List) { - runtime.setWakeWords(words) + fun setGatewayPassword(value: String) { + runtime.setGatewayPassword(value) } - fun resetWakeWordsDefaults() { - runtime.resetWakeWordsDefaults() + fun setOnboardingCompleted(value: Boolean) { + runtime.setOnboardingCompleted(value) } - fun setVoiceWakeMode(mode: VoiceWakeMode) { - runtime.setVoiceWakeMode(mode) + fun setCanvasDebugStatusEnabled(value: Boolean) { + runtime.setCanvasDebugStatusEnabled(value) } - fun setTalkEnabled(enabled: Boolean) { - runtime.setTalkEnabled(enabled) + fun setMicEnabled(enabled: Boolean) { + runtime.setMicEnabled(enabled) } fun refreshGatewayConnection() { @@ -158,6 +161,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } + fun requestCanvasRehydrate(source: String = "screen_tab") { + runtime.requestCanvasRehydrate(source = source, force = true) + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt index 2be9ee71a2c7..ab5e159cf476 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt @@ -2,23 +2,12 @@ package ai.openclaw.android import android.app.Application import android.os.StrictMode -import android.util.Log -import java.security.Security class NodeApp : Application() { val runtime: NodeRuntime by lazy { NodeRuntime(this) } override fun onCreate() { super.onCreate() - // Register Bouncy Castle as highest-priority provider for Ed25519 support - try { - val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") - .getDeclaredConstructor().newInstance() as java.security.Provider - Security.removeProvider("BC") - Security.insertProviderAt(bcProvider, 1) - } catch (it: Throwable) { - Log.e("NodeApp", "Failed to register Bouncy Castle provider", it) - } if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt index ee7c8e006747..a6a79dc9c4a9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt @@ -39,22 +39,22 @@ class NodeForegroundService : Service() { runtime.statusText, runtime.serverName, runtime.isConnected, - runtime.voiceWakeMode, - runtime.voiceWakeIsListening, - ) { status, server, connected, voiceMode, voiceListening -> - Quint(status, server, connected, voiceMode, voiceListening) - }.collect { (status, server, connected, voiceMode, voiceListening) -> + runtime.micEnabled, + runtime.micIsListening, + ) { status, server, connected, micEnabled, micListening -> + Quint(status, server, connected, micEnabled, micListening) + }.collect { (status, server, connected, micEnabled, micListening) -> val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node" - val voiceSuffix = - if (voiceMode == VoiceWakeMode.Always) { - if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" + val micSuffix = + if (micEnabled) { + if (micListening) " · Mic: Listening" else " · Mic: Pending" } else { "" } - val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix + val text = (server?.let { "$status · $it" } ?: status) + micSuffix val requiresMic = - voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() + micEnabled && hasRecordAudioPermission() startForegroundWithTypes( notification = buildNotification(title = title, text = text), requiresMic = requiresMic, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index aec192c25bbc..97a16d7af913 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.SystemClock +import android.util.Log import androidx.core.content.ContextCompat import ai.openclaw.android.chat.ChatController import ai.openclaw.android.chat.ChatMessage @@ -18,8 +19,8 @@ import ai.openclaw.android.gateway.GatewaySession import ai.openclaw.android.gateway.probeGatewayTlsFingerprint import ai.openclaw.android.node.* import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction -import ai.openclaw.android.voice.TalkModeManager -import ai.openclaw.android.voice.VoiceWakeManager +import ai.openclaw.android.voice.MicCaptureManager +import ai.openclaw.android.voice.VoiceConversationEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,6 +37,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import java.util.UUID import java.util.concurrent.atomic.AtomicLong class NodeRuntime(context: Context) { @@ -53,40 +55,6 @@ class NodeRuntime(context: Context) { private val externalAudioCaptureActive = MutableStateFlow(false) - private val voiceWake: VoiceWakeManager by lazy { - VoiceWakeManager( - context = appContext, - scope = scope, - onCommand = { command -> - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive(resolveMainSessionKey())) - put("thinking", JsonPrimitive(chatThinkingLevel.value)) - put("deliver", JsonPrimitive(false)) - }.toString(), - ) - }, - ) - } - - val voiceWakeIsListening: StateFlow - get() = voiceWake.isListening - - val voiceWakeStatusText: StateFlow - get() = voiceWake.statusText - - val talkStatusText: StateFlow - get() = talkMode.statusText - - val talkIsListening: StateFlow - get() = talkMode.isListening - - val talkIsSpeaking: StateFlow - get() = talkMode.isSpeaking - private val discovery = GatewayDiscovery(appContext, scope = scope) val gateways: StateFlow> = discovery.gateways val discoveryStatusText: StateFlow = discovery.statusText @@ -124,6 +92,14 @@ class NodeRuntime(context: Context) { locationPreciseEnabled = { locationPreciseEnabled.value }, ) + private val deviceHandler: DeviceHandler = DeviceHandler( + appContext = appContext, + ) + + private val notificationsHandler: NotificationsHandler = NotificationsHandler( + appContext = appContext, + ) + private val screenHandler: ScreenHandler = ScreenHandler( screenRecorder = screenRecorder, setScreenRecordActive = { _screenRecordActive.value = it }, @@ -145,7 +121,7 @@ class NodeRuntime(context: Context) { prefs = prefs, cameraEnabled = { cameraEnabled.value }, locationMode = { locationMode.value }, - voiceWakeMode = { voiceWakeMode.value }, + voiceWakeMode = { VoiceWakeMode.Off }, smsAvailable = { sms.canSendSms() }, hasRecordAudioPermission = { hasRecordAudioPermission() }, manualTls = { manualTls.value }, @@ -155,6 +131,8 @@ class NodeRuntime(context: Context) { canvas = canvas, cameraHandler = cameraHandler, locationHandler = locationHandler, + deviceHandler = deviceHandler, + notificationsHandler = notificationsHandler, screenHandler = screenHandler, smsHandler = smsHandlerImpl, a2uiHandler = a2uiHandler, @@ -163,10 +141,16 @@ class NodeRuntime(context: Context) { isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, + smsAvailable = { sms.canSendSms() }, + debugBuild = { BuildConfig.DEBUG }, + onCanvasA2uiPush = { + _canvasA2uiHydrated.value = true + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + }, + onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, ) - private lateinit var gatewayEventHandler: GatewayEventHandler - data class GatewayTrustPrompt( val endpoint: GatewayEndpoint, val fingerprintSha256: String, @@ -174,6 +158,8 @@ class NodeRuntime(context: Context) { private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private val _nodeConnected = MutableStateFlow(false) + val nodeConnected: StateFlow = _nodeConnected.asStateFlow() private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() @@ -194,6 +180,13 @@ class NodeRuntime(context: Context) { private val _screenRecordActive = MutableStateFlow(false) val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() + private val _canvasA2uiHydrated = MutableStateFlow(false) + val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow() + private val _canvasRehydratePending = MutableStateFlow(false) + val canvasRehydratePending: StateFlow = _canvasRehydratePending.asStateFlow() + private val _canvasRehydrateErrorText = MutableStateFlow(null) + val canvasRehydrateErrorText: StateFlow = _canvasRehydrateErrorText.asStateFlow() + private val _serverName = MutableStateFlow(null) val serverName: StateFlow = _serverName.asStateFlow() @@ -207,8 +200,9 @@ class NodeRuntime(context: Context) { val isForeground: StateFlow = _isForeground.asStateFlow() private var lastAutoA2uiUrl: String? = null + private var didAutoRequestCanvasRehydrate = false + private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false - private var nodeConnected = false private var operatorStatusText: String = "Offline" private var nodeStatusText: String = "Offline" @@ -225,8 +219,8 @@ class NodeRuntime(context: Context) { _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB applyMainSessionKey(mainSessionKey) updateStatus() + micCapture.onGatewayConnectionChanged(true) scope.launch { refreshBrandingFromGateway() } - scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() } }, onDisconnected = { message -> operatorConnected = false @@ -237,11 +231,10 @@ class NodeRuntime(context: Context) { if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { _mainSessionKey.value = "main" } - val mainKey = resolveMainSessionKey() - talkMode.setMainSessionKey(mainKey) - chat.applyMainSessionKey(mainKey) + chat.applyMainSessionKey(resolveMainSessionKey()) chat.onDisconnected(message) updateStatus() + micCapture.onGatewayConnectionChanged(false) }, onEvent = { event, payloadJson -> handleGatewayEvent(event, payloadJson) @@ -254,14 +247,22 @@ class NodeRuntime(context: Context) { identityStore = identityStore, deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> - nodeConnected = true + _nodeConnected.value = true nodeStatusText = "Connected" + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() maybeNavigateToA2uiOnConnect() }, onDisconnected = { message -> - nodeConnected = false + _nodeConnected.value = false nodeStatusText = message + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() showLocalCanvasOnDisconnect() }, @@ -281,34 +282,74 @@ class NodeRuntime(context: Context) { json = json, supportsChatSubscribe = false, ) - private val talkMode: TalkModeManager by lazy { - TalkModeManager( + private val micCapture: MicCaptureManager by lazy { + MicCaptureManager( context = appContext, scope = scope, - session = operatorSession, - supportsChatSubscribe = false, - isConnected = { operatorConnected }, + sendToGateway = { message -> + val idempotencyKey = UUID.randomUUID().toString() + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + put("message", JsonPrimitive(message)) + put("thinking", JsonPrimitive(chatThinkingLevel.value)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(idempotencyKey)) + } + val response = operatorSession.request("chat.send", params.toString()) + parseChatSendRunId(response) ?: idempotencyKey + }, ) } + val micStatusText: StateFlow + get() = micCapture.statusText + + val micLiveTranscript: StateFlow + get() = micCapture.liveTranscript + + val micIsListening: StateFlow + get() = micCapture.isListening + + val micEnabled: StateFlow + get() = micCapture.micEnabled + + val micQueuedMessages: StateFlow> + get() = micCapture.queuedMessages + + val micConversation: StateFlow> + get() = micCapture.conversation + + val micInputLevel: StateFlow + get() = micCapture.inputLevel + + val micIsSending: StateFlow + get() = micCapture.isSending + private fun applyMainSessionKey(candidate: String?) { val trimmed = normalizeMainKey(candidate) ?: return if (isCanonicalMainSessionKey(_mainSessionKey.value)) return if (_mainSessionKey.value == trimmed) return _mainSessionKey.value = trimmed - talkMode.setMainSessionKey(trimmed) chat.applyMainSessionKey(trimmed) } private fun updateStatus() { _isConnected.value = operatorConnected + val operator = operatorStatusText.trim() + val node = nodeStatusText.trim() _statusText.value = when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" - operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText - else -> nodeStatusText + operatorConnected && _nodeConnected.value -> "Connected" + operatorConnected && !_nodeConnected.value -> "Connected (node offline)" + !operatorConnected && _nodeConnected.value -> + if (operator.isNotEmpty() && operator != "Offline") { + "Connected (operator: $operator)" + } else { + "Connected (operator offline)" + } + operator.isNotBlank() && operator != "Offline" -> operator + else -> node } } @@ -328,24 +369,78 @@ class NodeRuntime(context: Context) { private fun showLocalCanvasOnDisconnect() { lastAutoA2uiUrl = null + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null canvas.navigate("") } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { + scope.launch { + if (!_nodeConnected.value) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Node offline. Reconnect and retry." + return@launch + } + if (!force && didAutoRequestCanvasRehydrate) return@launch + didAutoRequestCanvasRehydrate = true + val requestId = canvasRehydrateSeq.incrementAndGet() + _canvasRehydratePending.value = true + _canvasRehydrateErrorText.value = null + + val sessionKey = resolveMainSessionKey() + val prompt = + "Restore canvas now for session=$sessionKey source=$source. " + + "If existing A2UI state exists, replay it immediately. " + + "If not, create and render a compact mobile-friendly dashboard in Canvas." + val sent = + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(prompt)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + if (!sent) { + if (!force) { + didAutoRequestCanvasRehydrate = false + } + if (canvasRehydrateSeq.get() == requestId) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry." + } + Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable") + return@launch + } + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." + } + } + } + val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled val locationMode: StateFlow = prefs.locationMode val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled val preventSleep: StateFlow = prefs.preventSleep - val wakeWords: StateFlow> = prefs.wakeWords - val voiceWakeMode: StateFlow = prefs.voiceWakeMode - val talkEnabled: StateFlow = prefs.talkEnabled val manualEnabled: StateFlow = prefs.manualEnabled val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort val manualTls: StateFlow = prefs.manualTls val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) + fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled @@ -363,50 +458,17 @@ class NodeRuntime(context: Context) { val pendingRunCount: StateFlow = chat.pendingRunCount init { - gatewayEventHandler = GatewayEventHandler( - scope = scope, - prefs = prefs, - json = json, - operatorSession = operatorSession, - isConnected = { _isConnected.value }, - ) + if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) { + prefs.setVoiceWakeMode(VoiceWakeMode.Off) + } scope.launch { - combine( - voiceWakeMode, - isForeground, - externalAudioCaptureActive, - wakeWords, - ) { mode, foreground, externalAudio, words -> - Quad(mode, foreground, externalAudio, words) - }.distinctUntilChanged() - .collect { (mode, foreground, externalAudio, words) -> - voiceWake.setTriggerWords(words) - - val shouldListen = - when (mode) { - VoiceWakeMode.Off -> false - VoiceWakeMode.Foreground -> foreground - VoiceWakeMode.Always -> true - } && !externalAudio - - if (!shouldListen) { - voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") - return@collect - } - - if (!hasRecordAudioPermission()) { - voiceWake.stop(statusText = "Microphone permission required") - return@collect - } - - voiceWake.start() - } + prefs.loadGatewayToken() } scope.launch { - talkEnabled.collect { enabled -> - talkMode.setEnabled(enabled) + prefs.talkEnabled.collect { enabled -> + micCapture.setMicEnabled(enabled) externalAudioCaptureActive.value = enabled } } @@ -514,25 +576,20 @@ class NodeRuntime(context: Context) { prefs.setCanvasDebugStatusEnabled(value) } - fun setWakeWords(words: List) { - prefs.setWakeWords(words) - gatewayEventHandler.scheduleWakeWordsSyncIfNeeded() - } - - fun resetWakeWordsDefaults() { - setWakeWords(SecurePrefs.defaultWakeWords) - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(value: Boolean) { + fun setMicEnabled(value: Boolean) { prefs.setTalkEnabled(value) + micCapture.setMicEnabled(value) + externalAudioCaptureActive.value = value } fun refreshGatewayConnection() { - val endpoint = connectedEndpoint ?: return + val endpoint = + connectedEndpoint ?: run { + _statusText.value = "Failed: no cached gateway endpoint" + return + } + operatorStatusText = "Connecting…" + updateStatus() val token = prefs.loadGatewayToken() val password = prefs.loadGatewayPassword() val tls = connectionManager.resolveTlsParams(endpoint) @@ -639,10 +696,10 @@ class NodeRuntime(context: Context) { contextJson = contextJson, ) - val connected = nodeConnected + val connected = _nodeConnected.value var error: String? = null if (connected) { - try { + val sent = nodeSession.sendNodeEvent( event = "agent.request", payloadJson = @@ -654,8 +711,8 @@ class NodeRuntime(context: Context) { put("key", JsonPrimitive(actionId)) }.toString(), ) - } catch (e: Throwable) { - error = e.message ?: "send failed" + if (!sent) { + error = "send failed" } } else { error = "gateway not connected" @@ -705,15 +762,19 @@ class NodeRuntime(context: Context) { } private fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event == "voicewake.changed") { - gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson) - return - } - - talkMode.handleGatewayEvent(event, payloadJson) + micCapture.handleGatewayEvent(event, payloadJson) chat.handleGatewayEvent(event, payloadJson) } + private fun parseChatSendRunId(response: String): String? { + return try { + val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null + root["runId"].asStringOrNull() + } catch (_: Throwable) { + null + } + } + private suspend fun refreshBrandingFromGateway() { if (!_isConnected.value) return try { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 29ef4a3eaae1..96e4572955ec 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -20,19 +20,21 @@ class SecurePrefs(context: Context) { val defaultWakeWords: List = listOf("openclaw", "claude") private const val displayNameKey = "node.displayName" private const val voiceWakeModeKey = "voiceWake.mode" + private const val plainPrefsName = "openclaw.node" + private const val securePrefsName = "openclaw.node.secure" } private val appContext = context.applicationContext private val json = Json { ignoreUnknownKeys = true } + private val plainPrefs: SharedPreferences = + appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE) - private val masterKey = - MasterKey.Builder(context) + private val masterKey by lazy { + MasterKey.Builder(appContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - - private val prefs: SharedPreferences by lazy { - createPrefs(appContext, "openclaw.node.secure") } + private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) } private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) val instanceId: StateFlow = _instanceId @@ -41,48 +43,51 @@ class SecurePrefs(context: Context) { MutableStateFlow(loadOrMigrateDisplayName(context = context)) val displayName: StateFlow = _displayName - private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) + private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true)) val cameraEnabled: StateFlow = _cameraEnabled private val _locationMode = - MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) + MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off"))) val locationMode: StateFlow = _locationMode private val _locationPreciseEnabled = - MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) + MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true)) val locationPreciseEnabled: StateFlow = _locationPreciseEnabled - private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) + private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true)) val preventSleep: StateFlow = _preventSleep private val _manualEnabled = - MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false)) + MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false)) val manualEnabled: StateFlow = _manualEnabled private val _manualHost = - MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "") + MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "") val manualHost: StateFlow = _manualHost private val _manualPort = - MutableStateFlow(prefs.getInt("gateway.manual.port", 18789)) + MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789)) val manualPort: StateFlow = _manualPort private val _manualTls = - MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) + MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true)) val manualTls: StateFlow = _manualTls - private val _gatewayToken = - MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") + private val _gatewayToken = MutableStateFlow("") val gatewayToken: StateFlow = _gatewayToken + private val _onboardingCompleted = + MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false)) + val onboardingCompleted: StateFlow = _onboardingCompleted + private val _lastDiscoveredStableId = MutableStateFlow( - prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", + plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "", ) val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId private val _canvasDebugStatusEnabled = - MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) + MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false)) val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled private val _wakeWords = MutableStateFlow(loadWakeWords()) @@ -91,119 +96,134 @@ class SecurePrefs(context: Context) { private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) val voiceWakeMode: StateFlow = _voiceWakeMode - private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) + private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false)) val talkEnabled: StateFlow = _talkEnabled fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() - prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } + plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } _lastDiscoveredStableId.value = trimmed } fun setDisplayName(value: String) { val trimmed = value.trim() - prefs.edit { putString(displayNameKey, trimmed) } + plainPrefs.edit { putString(displayNameKey, trimmed) } _displayName.value = trimmed } fun setCameraEnabled(value: Boolean) { - prefs.edit { putBoolean("camera.enabled", value) } + plainPrefs.edit { putBoolean("camera.enabled", value) } _cameraEnabled.value = value } fun setLocationMode(mode: LocationMode) { - prefs.edit { putString("location.enabledMode", mode.rawValue) } + plainPrefs.edit { putString("location.enabledMode", mode.rawValue) } _locationMode.value = mode } fun setLocationPreciseEnabled(value: Boolean) { - prefs.edit { putBoolean("location.preciseEnabled", value) } + plainPrefs.edit { putBoolean("location.preciseEnabled", value) } _locationPreciseEnabled.value = value } fun setPreventSleep(value: Boolean) { - prefs.edit { putBoolean("screen.preventSleep", value) } + plainPrefs.edit { putBoolean("screen.preventSleep", value) } _preventSleep.value = value } fun setManualEnabled(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.enabled", value) } + plainPrefs.edit { putBoolean("gateway.manual.enabled", value) } _manualEnabled.value = value } fun setManualHost(value: String) { val trimmed = value.trim() - prefs.edit { putString("gateway.manual.host", trimmed) } + plainPrefs.edit { putString("gateway.manual.host", trimmed) } _manualHost.value = trimmed } fun setManualPort(value: Int) { - prefs.edit { putInt("gateway.manual.port", value) } + plainPrefs.edit { putInt("gateway.manual.port", value) } _manualPort.value = value } fun setManualTls(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.tls", value) } + plainPrefs.edit { putBoolean("gateway.manual.tls", value) } _manualTls.value = value } fun setGatewayToken(value: String) { - prefs.edit { putString("gateway.manual.token", value) } - _gatewayToken.value = value + val trimmed = value.trim() + securePrefs.edit { putString("gateway.manual.token", trimmed) } + _gatewayToken.value = trimmed + } + + fun setGatewayPassword(value: String) { + saveGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + plainPrefs.edit { putBoolean("onboarding.completed", value) } + _onboardingCompleted.value = value } fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } + plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) } _canvasDebugStatusEnabled.value = value } fun loadGatewayToken(): String? { - val manual = _gatewayToken.value.trim() + val manual = + _gatewayToken.value.trim().ifEmpty { + val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty() + if (stored.isNotEmpty()) _gatewayToken.value = stored + stored + } if (manual.isNotEmpty()) return manual val key = "gateway.token.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() + val stored = securePrefs.getString(key, null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveGatewayToken(token: String) { val key = "gateway.token.${_instanceId.value}" - prefs.edit { putString(key, token.trim()) } + securePrefs.edit { putString(key, token.trim()) } } fun loadGatewayPassword(): String? { val key = "gateway.password.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() + val stored = securePrefs.getString(key, null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveGatewayPassword(password: String) { val key = "gateway.password.${_instanceId.value}" - prefs.edit { putString(key, password.trim()) } + securePrefs.edit { putString(key, password.trim()) } } fun loadGatewayTlsFingerprint(stableId: String): String? { val key = "gateway.tls.$stableId" - return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } + return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } } fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { val key = "gateway.tls.$stableId" - prefs.edit { putString(key, fingerprint.trim()) } + plainPrefs.edit { putString(key, fingerprint.trim()) } } fun getString(key: String): String? { - return prefs.getString(key, null) + return securePrefs.getString(key, null) } fun putString(key: String, value: String) { - prefs.edit { putString(key, value) } + securePrefs.edit { putString(key, value) } } fun remove(key: String) { - prefs.edit { remove(key) } + securePrefs.edit { remove(key) } } - private fun createPrefs(context: Context, name: String): SharedPreferences { + private fun createSecurePrefs(context: Context, name: String): SharedPreferences { return EncryptedSharedPreferences.create( context, name, @@ -214,21 +234,21 @@ class SecurePrefs(context: Context) { } private fun loadOrCreateInstanceId(): String { - val existing = prefs.getString("node.instanceId", null)?.trim() + val existing = plainPrefs.getString("node.instanceId", null)?.trim() if (!existing.isNullOrBlank()) return existing val fresh = UUID.randomUUID().toString() - prefs.edit { putString("node.instanceId", fresh) } + plainPrefs.edit { putString("node.instanceId", fresh) } return fresh } private fun loadOrMigrateDisplayName(context: Context): String { - val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() + val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty() if (existing.isNotEmpty() && existing != "Android Node") return existing val candidate = DeviceNames.bestDefaultNodeName(context).trim() val resolved = candidate.ifEmpty { "Android Node" } - prefs.edit { putString(displayNameKey, resolved) } + plainPrefs.edit { putString(displayNameKey, resolved) } return resolved } @@ -236,34 +256,34 @@ class SecurePrefs(context: Context) { val sanitized = WakeWords.sanitize(words, defaultWakeWords) val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString() - prefs.edit { putString("voiceWake.triggerWords", encoded) } + plainPrefs.edit { putString("voiceWake.triggerWords", encoded) } _wakeWords.value = sanitized } fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } + plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) } _voiceWakeMode.value = mode } fun setTalkEnabled(value: Boolean) { - prefs.edit { putBoolean("talk.enabled", value) } + plainPrefs.edit { putBoolean("talk.enabled", value) } _talkEnabled.value = value } private fun loadVoiceWakeMode(): VoiceWakeMode { - val raw = prefs.getString(voiceWakeModeKey, null) + val raw = plainPrefs.getString(voiceWakeModeKey, null) val resolved = VoiceWakeMode.fromRawValue(raw) // Default ON (foreground) when unset. if (raw.isNullOrBlank()) { - prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } + plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } } return resolved } private fun loadWakeWords(): List { - val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() + val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim() if (raw.isNullOrEmpty()) return defaultWakeWords return try { val element = json.parseToJsonElement(raw) @@ -281,5 +301,4 @@ class SecurePrefs(context: Context) { defaultWakeWords } } - } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt index 3ed69ee5b243..335f3b0d70be 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt @@ -261,11 +261,7 @@ class ChatController( val key = _sessionKey.value try { if (supportsChatSubscribe) { - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - } catch (_: Throwable) { - // best-effort - } + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") @@ -325,6 +321,12 @@ class ChatController( val state = payload["state"].asStringOrNull() when (state) { + "delta" -> { + val text = parseAssistantDeltaText(payload) + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } "final", "aborted", "error" -> { if (state == "error") { _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" @@ -351,9 +353,8 @@ class ChatController( private fun handleAgentEvent(payloadJson: String) { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val runId = payload["runId"].asStringOrNull() - val sessionId = _sessionId.value - if (sessionId != null && runId != sessionId) return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return val stream = payload["stream"].asStringOrNull() val data = payload["data"].asObjectOrNull() @@ -398,6 +399,21 @@ class ChatController( } } + private fun parseAssistantDeltaText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"].asArrayOrNull() ?: return null + for (item in content) { + val obj = item.asObjectOrNull() ?: continue + if (obj["type"].asStringOrNull() != "text") continue + val text = obj["text"].asStringOrNull() + if (!text.isNullOrEmpty()) { + return text + } + } + return null + } + private fun publishPendingToolCalls() { _pendingToolCalls.value = pendingToolCallsById.values.sortedBy { it.startedAtMs } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt new file mode 100644 index 000000000000..9fecaa03b55b --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthPayload.kt @@ -0,0 +1,52 @@ +package ai.openclaw.android.gateway + +internal object DeviceAuthPayload { + fun buildV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: List, + signedAtMs: Long, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?, + ): String { + val scopeString = scopes.joinToString(",") + val authToken = token.orEmpty() + val platformNorm = normalizeMetadataField(platform) + val deviceFamilyNorm = normalizeMetadataField(deviceFamily) + return listOf( + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + signedAtMs.toString(), + authToken, + nonce, + platformNorm, + deviceFamilyNorm, + ).joinToString("|") + } + + internal fun normalizeMetadataField(value: String?): String { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) { + return "" + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin): + // lowercase ASCII A-Z only for auth payload metadata fields. + val out = StringBuilder(trimmed.length) + for (ch in trimmed) { + if (ch in 'A'..'Z') { + out.append((ch.code + 32).toChar()) + } else { + out.append(ch) + } + } + return out.toString() + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt index 810e029fba89..8ace62e087c3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt @@ -2,13 +2,18 @@ package ai.openclaw.android.gateway import ai.openclaw.android.SecurePrefs -class DeviceAuthStore(private val prefs: SecurePrefs) { - fun loadToken(deviceId: String, role: String): String? { +interface DeviceAuthTokenStore { + fun loadToken(deviceId: String, role: String): String? + fun saveToken(deviceId: String, role: String, token: String) +} + +class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { + override fun loadToken(deviceId: String, role: String): String? { val key = tokenKey(deviceId, role) return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } } - fun saveToken(deviceId: String, role: String, token: String) { + override fun saveToken(deviceId: String, role: String, token: String) { val key = tokenKey(deviceId, role) prefs.putString(key, token.trim()) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt index ff651c6c17b0..68830772f9a5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt @@ -3,11 +3,7 @@ package ai.openclaw.android.gateway import android.content.Context import android.util.Base64 import java.io.File -import java.security.KeyFactory -import java.security.KeyPairGenerator import java.security.MessageDigest -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -22,21 +18,26 @@ data class DeviceIdentity( class DeviceIdentityStore(context: Context) { private val json = Json { ignoreUnknownKeys = true } private val identityFile = File(context.filesDir, "openclaw/identity/device.json") + @Volatile private var cachedIdentity: DeviceIdentity? = null @Synchronized fun loadOrCreate(): DeviceIdentity { + cachedIdentity?.let { return it } val existing = load() if (existing != null) { val derived = deriveDeviceId(existing.publicKeyRawBase64) if (derived != null && derived != existing.deviceId) { val updated = existing.copy(deviceId = derived) save(updated) + cachedIdentity = updated return updated } + cachedIdentity = existing return existing } val fresh = generate() save(fresh) + cachedIdentity = fresh return fresh } @@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) { } } - private fun stripSpkiPrefix(spki: ByteArray): ByteArray { - if (spki.size == ED25519_SPKI_PREFIX.size + 32 && - spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) - ) { - return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) - } - return spki - } - private fun sha256Hex(data: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) + val out = CharArray(digest.size * 2) + var i = 0 for (byte in digest) { - out.append(String.format("%02x", byte)) + val v = byte.toInt() and 0xff + out[i++] = HEX[v ushr 4] + out[i++] = HEX[v and 0x0f] } - return out.toString() + return String(out) } private fun base64UrlEncode(data: ByteArray): String { @@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) { } companion object { - private val ED25519_SPKI_PREFIX = - byteArrayOf( - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ) + private val HEX = "0123456789abcdef".toCharArray() } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 091e735530d7..7c8b13ec396a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -55,13 +55,18 @@ data class GatewayConnectOptions( class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, - private val deviceAuthStore: DeviceAuthStore, + private val deviceAuthStore: DeviceAuthTokenStore, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, ) { + private companion object { + // Keep connect timeout above observed gateway unauthorized close on lower-end devices. + private const val CONNECT_RPC_TIMEOUT_MS = 12_000L + } + data class InvokeRequest( val id: String, val nodeId: String, @@ -131,8 +136,8 @@ class GatewaySession( fun currentCanvasHostUrl(): String? = canvasHostUrl fun currentMainSessionKey(): String? = mainSessionKey - suspend fun sendNodeEvent(event: String, payloadJson: String?) { - val conn = currentConnection ?: return + suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean { + val conn = currentConnection ?: return false val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } val params = buildJsonObject { @@ -147,8 +152,10 @@ class GatewaySession( } try { conn.request("node.event", params, timeoutMs = 8_000) + return true } catch (err: Throwable) { Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") + return false } } @@ -178,7 +185,7 @@ class GatewaySession( private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() private val isClosed = AtomicBoolean(false) - private val connectNonceDeferred = CompletableDeferred() + private val connectNonceDeferred = CompletableDeferred() private val client: OkHttpClient = buildClient() private var socket: WebSocket? = null private val loggerTag = "OpenClawGateway" @@ -193,9 +200,7 @@ class GatewaySession( suspend fun connect() { val scheme = if (tls != null) "wss" else "ws" val url = "$scheme://${endpoint.host}:${endpoint.port}" - val httpScheme = if (tls != null) "https" else "http" - val origin = "$httpScheme://${endpoint.host}:${endpoint.port}" - val request = Request.Builder().url(url).header("Origin", origin).build() + val request = Request.Builder().url(url).build() socket = client.newWebSocket(request, Listener()) try { connectDeferred.await() @@ -296,21 +301,23 @@ class GatewaySession( } } - private suspend fun sendConnect(connectNonce: String?) { + private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() - val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken - val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() + // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. + val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) + val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) if (!res.ok) { val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { - deviceAuthStore.clearToken(identity.deviceId, options.role) - } throw IllegalStateException(msg) } + handleConnectSuccess(res, identity.deviceId) + connectDeferred.complete(Unit) + } + + private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() @@ -318,21 +325,20 @@ class GatewaySession( val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) + deviceAuthStore.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() - canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) + canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) val sessionDefaults = obj["snapshot"].asObjectOrNull() ?.get("sessionDefaults").asObjectOrNull() mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() onConnected(serverName, remoteAddress, mainSessionKey) - connectDeferred.complete(Unit) } private fun buildConnectParams( identity: DeviceIdentity, - connectNonce: String?, + connectNonce: String, authToken: String, authPassword: String?, ): JsonObject { @@ -366,7 +372,7 @@ class GatewaySession( val signedAtMs = System.currentTimeMillis() val payload = - buildDeviceAuthPayload( + DeviceAuthPayload.buildV3( deviceId = identity.deviceId, clientId = client.id, clientMode = client.mode, @@ -375,6 +381,8 @@ class GatewaySession( signedAtMs = signedAtMs, token = if (authToken.isNotEmpty()) authToken else null, nonce = connectNonce, + platform = client.platform, + deviceFamily = client.deviceFamily, ) val signature = identityStore.signPayload(payload, identity) val publicKey = identityStore.publicKeyBase64Url(identity) @@ -385,9 +393,7 @@ class GatewaySession( put("publicKey", JsonPrimitive(publicKey)) put("signature", JsonPrimitive(signature)) put("signedAt", JsonPrimitive(signedAtMs)) - if (!connectNonce.isNullOrBlank()) { - put("nonce", JsonPrimitive(connectNonce)) - } + put("nonce", JsonPrimitive(connectNonce)) } } else { null @@ -447,8 +453,8 @@ class GatewaySession( frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() if (event == "connect.challenge") { val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted) { - connectNonceDeferred.complete(nonce) + if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) { + connectNonceDeferred.complete(nonce.trim()) } return } @@ -459,12 +465,11 @@ class GatewaySession( onEvent(event, payloadJson) } - private suspend fun awaitConnectNonce(): String? { - if (isLoopbackHost(endpoint.host)) return null + private suspend fun awaitConnectNonce(): String { return try { withTimeout(2_000) { connectNonceDeferred.await() } - } catch (_: Throwable) { - null + } catch (err: Throwable) { + throw IllegalStateException("connect challenge timeout", err) } } @@ -530,16 +535,8 @@ class GatewaySession( } private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { - val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName - val parts = msg.split(":", limit = 2) - if (parts.size == 2) { - val code = parts[0].trim() - val rest = parts[1].trim() - if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { - return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) - } - } - return InvokeResult.error(code = "UNAVAILABLE", message = msg) + val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName) + return InvokeResult.error(code = parsed.code, message = parsed.message) } private fun failPending() { @@ -587,54 +584,30 @@ class GatewaySession( } } - private fun buildDeviceAuthPayload( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: List, - signedAtMs: Long, - token: String?, - nonce: String?, - ): String { - val scopeString = scopes.joinToString(",") - val authToken = token.orEmpty() - val version = if (nonce.isNullOrBlank()) "v1" else "v2" - val parts = - mutableListOf( - version, - deviceId, - clientId, - clientMode, - role, - scopeString, - signedAtMs.toString(), - authToken, - ) - if (!nonce.isNullOrBlank()) { - parts.add(nonce) - } - return parts.joinToString("|") - } - - private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { + private fun normalizeCanvasHostUrl( + raw: String?, + endpoint: GatewayEndpoint, + isTlsConnection: Boolean, + ): String? { val trimmed = raw?.trim().orEmpty() val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } val host = parsed?.host?.trim().orEmpty() val port = parsed?.port ?: -1 val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } - - // Detect TLS reverse proxy: endpoint on port 443, or domain-based host - val tls = endpoint.port == 443 || endpoint.host.contains(".") - - // If raw URL is a non-loopback address AND we're behind TLS reverse proxy, - // fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy) - if (trimmed.isNotBlank() && !isLoopbackHost(host)) { - if (tls && port > 0 && port != 443) { - // Rewrite the URL to use the reverse proxy port instead of the raw gateway port - val fixedScheme = "https" - val formattedHost = if (host.contains(":")) "[${host}]" else host - return "$fixedScheme://$formattedHost" + val suffix = buildUrlSuffix(parsed) + + // If raw URL is a non-loopback address and this connection uses TLS, + // normalize scheme/port to the endpoint we actually connected to. + if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) { + val needsTlsRewrite = + isTlsConnection && + ( + !scheme.equals("https", ignoreCase = true) || + (port > 0 && port != endpoint.port) || + (port <= 0 && endpoint.port != 443) + ) + if (needsTlsRewrite) { + return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix) } return trimmed } @@ -645,14 +618,26 @@ class GatewaySession( ?: endpoint.host.trim() if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } - // When connecting through a reverse proxy (TLS on standard port), use the - // connection endpoint's scheme and port instead of the raw canvas port. - val fallbackScheme = if (tls) "https" else scheme - // Behind reverse proxy, always use the proxy port (443), not the raw canvas port - val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port) - val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost - val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort" - return "$fallbackScheme://$formattedHost$portSuffix" + // For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata. + val fallbackScheme = if (isTlsConnection) "https" else scheme + // For TLS, always use the connected endpoint port. + val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port) + return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix) + } + + private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String { + val loweredScheme = scheme.lowercase() + val formattedHost = if (host.contains(":")) "[${host}]" else host + val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port" + return "$loweredScheme://$formattedHost$portSuffix$suffix" + } + + private fun buildUrlSuffix(uri: java.net.URI?): String { + if (uri == null) return "" + val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: "" + val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: "" + val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: "" + return "$path$query$fragment" } private fun isLoopbackHost(raw: String?): Boolean { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt new file mode 100644 index 000000000000..7242f4a55333 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt @@ -0,0 +1,39 @@ +package ai.openclaw.android.gateway + +data class ParsedInvokeError( + val code: String, + val message: String, + val hadExplicitCode: Boolean, +) { + val prefixedMessage: String + get() = "$code: $message" +} + +fun parseInvokeErrorMessage(raw: String): ParsedInvokeError { + val trimmed = raw.trim() + if (trimmed.isEmpty()) { + return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false) + } + + val parts = trimmed.split(":", limit = 2) + if (parts.size == 2) { + val code = parts[0].trim() + val rest = parts[1].trim() + if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { + return ParsedInvokeError( + code = code, + message = rest.ifEmpty { trimmed }, + hadExplicitCode = true, + ) + } + } + return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false) +} + +fun parseInvokeErrorFromThrowable( + err: Throwable, + fallbackMessage: String = "error", +): ParsedInvokeError { + val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage + return parseInvokeErrorMessage(raw) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt index 65bac915effa..aa038ad9a945 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt @@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) { ensureCameraPermission() val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") val facing = parseFacing(paramsJson) ?: "front" - val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0) - val maxWidth = parseMaxWidth(paramsJson) ?: 800 + val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0) + val maxWidth = parseMaxWidth(paramsJson) ?: 1600 val provider = context.cameraProvider() val capture = ImageCapture.Builder().build() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt index c46770a6367f..d0747ee32b00 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -10,6 +10,9 @@ import androidx.core.graphics.scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.io.ByteArrayOutputStream import android.util.Base64 import org.json.JSONObject @@ -31,6 +34,8 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + private val _currentUrl = MutableStateFlow(null) + val currentUrl: StateFlow = _currentUrl.asStateFlow() private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" @@ -45,9 +50,16 @@ class CanvasController { applyDebugStatus() } + fun detach(webView: WebView) { + if (this.webView === webView) { + this.webView = null + } + } + fun navigate(url: String) { val trimmed = url.trim() this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed + _currentUrl.value = this.url reload() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index d15d928e0a45..1c9a045c8961 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -7,12 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo import ai.openclaw.android.gateway.GatewayConnectOptions import ai.openclaw.android.gateway.GatewayEndpoint import ai.openclaw.android.gateway.GatewayTlsParams -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand import ai.openclaw.android.protocol.OpenClawCapability import ai.openclaw.android.LocationMode import ai.openclaw.android.VoiceWakeMode @@ -80,37 +74,18 @@ class ConnectionManager( } fun buildInvokeCommands(): List = - buildList { - add(OpenClawCanvasCommand.Present.rawValue) - add(OpenClawCanvasCommand.Hide.rawValue) - add(OpenClawCanvasCommand.Navigate.rawValue) - add(OpenClawCanvasCommand.Eval.rawValue) - add(OpenClawCanvasCommand.Snapshot.rawValue) - add(OpenClawCanvasA2UICommand.Push.rawValue) - add(OpenClawCanvasA2UICommand.PushJSONL.rawValue) - add(OpenClawCanvasA2UICommand.Reset.rawValue) - add(OpenClawScreenCommand.Record.rawValue) - if (cameraEnabled()) { - add(OpenClawCameraCommand.Snap.rawValue) - add(OpenClawCameraCommand.Clip.rawValue) - } - if (locationMode() != LocationMode.Off) { - add(OpenClawLocationCommand.Get.rawValue) - } - if (smsAvailable()) { - add(OpenClawSmsCommand.Send.rawValue) - } - if (BuildConfig.DEBUG) { - add("debug.logs") - add("debug.ed25519") - } - add("app.update") - } + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = cameraEnabled(), + locationEnabled = locationMode() != LocationMode.Off, + smsAvailable = smsAvailable(), + debugBuild = BuildConfig.DEBUG, + ) fun buildCapabilities(): List = buildList { add(OpenClawCapability.Canvas.rawValue) add(OpenClawCapability.Screen.rawValue) + add(OpenClawCapability.Device.rawValue) if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue) if (smsAvailable()) add(OpenClawCapability.Sms.rawValue) if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) { @@ -176,7 +151,7 @@ class ConnectionManager( caps = emptyList(), commands = emptyList(), permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"), + client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"), userAgent = buildUserAgent(), ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt new file mode 100644 index 000000000000..896d7c7c74c0 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceHandler.kt @@ -0,0 +1,171 @@ +package ai.openclaw.android.node + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.os.Environment +import android.os.PowerManager +import android.os.StatFs +import android.os.SystemClock +import ai.openclaw.android.BuildConfig +import ai.openclaw.android.gateway.GatewaySession +import java.util.Locale +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class DeviceHandler( + private val appContext: Context, +) { + fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult { + return GatewaySession.InvokeResult.ok(statusPayloadJson()) + } + + fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult { + return GatewaySession.InvokeResult.ok(infoPayloadJson()) + } + + private fun statusPayloadJson(): String { + val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val batteryStatus = + batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + ?: BatteryManager.BATTERY_STATUS_UNKNOWN + val batteryLevel = batteryLevelFraction(batteryIntent) + val powerManager = appContext.getSystemService(PowerManager::class.java) + val storage = StatFs(Environment.getDataDirectory().absolutePath) + val totalBytes = storage.totalBytes + val freeBytes = storage.availableBytes + val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L) + val connectivity = appContext.getSystemService(ConnectivityManager::class.java) + val activeNetwork = connectivity?.activeNetwork + val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) } + val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0 + + return buildJsonObject { + put( + "battery", + buildJsonObject { + batteryLevel?.let { put("level", JsonPrimitive(it)) } + put("state", JsonPrimitive(mapBatteryState(batteryStatus))) + put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true)) + }, + ) + put( + "thermal", + buildJsonObject { + put("state", JsonPrimitive(mapThermalState(powerManager))) + }, + ) + put( + "storage", + buildJsonObject { + put("totalBytes", JsonPrimitive(totalBytes)) + put("freeBytes", JsonPrimitive(freeBytes)) + put("usedBytes", JsonPrimitive(usedBytes)) + }, + ) + put( + "network", + buildJsonObject { + put("status", JsonPrimitive(mapNetworkStatus(caps))) + put( + "isExpensive", + JsonPrimitive( + caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false, + ), + ) + put( + "isConstrained", + JsonPrimitive( + caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false, + ), + ) + put("interfaces", networkInterfacesJson(caps)) + }, + ) + put("uptimeSeconds", JsonPrimitive(uptimeSeconds)) + }.toString() + } + + private fun infoPayloadJson(): String { + val model = Build.MODEL?.trim().orEmpty() + val manufacturer = Build.MANUFACTURER?.trim().orEmpty() + val modelIdentifier = Build.DEVICE?.trim().orEmpty() + val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty() + val locale = Locale.getDefault().toLanguageTag().trim() + val appVersion = BuildConfig.VERSION_NAME.trim() + val appBuild = BuildConfig.VERSION_CODE.toString() + + return buildJsonObject { + put("deviceName", JsonPrimitive(model.ifEmpty { "Android" })) + put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") })) + put("systemName", JsonPrimitive("Android")) + put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() })) + put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" })) + put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" })) + put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() })) + }.toString() + } + + private fun batteryLevelFraction(intent: Intent?): Double? { + val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + if (rawLevel < 0 || rawScale <= 0) return null + return rawLevel.toDouble() / rawScale.toDouble() + } + + private fun mapBatteryState(status: Int): String { + return when (status) { + BatteryManager.BATTERY_STATUS_CHARGING -> "charging" + BatteryManager.BATTERY_STATUS_FULL -> "full" + BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged" + else -> "unknown" + } + } + + private fun mapThermalState(powerManager: PowerManager?): String { + val thermal = powerManager?.currentThermalStatus ?: return "nominal" + return when (thermal) { + PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal" + PowerManager.THERMAL_STATUS_MODERATE -> "fair" + PowerManager.THERMAL_STATUS_SEVERE -> "serious" + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical" + else -> "nominal" + } + } + + private fun mapNetworkStatus(caps: NetworkCapabilities?): String { + if (caps == null) return "unsatisfied" + return when { + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied" + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection" + else -> "unsatisfied" + } + } + + private fun networkInterfacesJson(caps: NetworkCapabilities?) = + buildJsonArray { + if (caps == null) return@buildJsonArray + var hasKnownTransport = false + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + hasKnownTransport = true + add(JsonPrimitive("wifi")) + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + hasKnownTransport = true + add(JsonPrimitive("cellular")) + } + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + hasKnownTransport = true + add(JsonPrimitive("wired")) + } + if (!hasKnownTransport) add(JsonPrimitive("other")) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt new file mode 100644 index 000000000000..709e9af5ec54 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt @@ -0,0 +1,164 @@ +package ai.openclaw.android.node + +import android.app.Notification +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification + +private const val MAX_NOTIFICATION_TEXT_CHARS = 512 + +internal fun sanitizeNotificationText(value: CharSequence?): String? { + val normalized = value?.toString()?.trim().orEmpty() + return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null } +} + +data class DeviceNotificationEntry( + val key: String, + val packageName: String, + val title: String?, + val text: String?, + val subText: String?, + val category: String?, + val channelId: String?, + val postTimeMs: Long, + val isOngoing: Boolean, + val isClearable: Boolean, +) + +data class DeviceNotificationSnapshot( + val enabled: Boolean, + val connected: Boolean, + val notifications: List, +) + +private object DeviceNotificationStore { + private val lock = Any() + private var connected = false + private val byKey = LinkedHashMap() + + fun replace(entries: List) { + synchronized(lock) { + byKey.clear() + for (entry in entries) { + byKey[entry.key] = entry + } + } + } + + fun upsert(entry: DeviceNotificationEntry) { + synchronized(lock) { + byKey[entry.key] = entry + } + } + + fun remove(key: String) { + synchronized(lock) { + byKey.remove(key) + } + } + + fun setConnected(value: Boolean) { + synchronized(lock) { + connected = value + if (!value) { + byKey.clear() + } + } + } + + fun snapshot(enabled: Boolean): DeviceNotificationSnapshot { + val (isConnected, entries) = + synchronized(lock) { + connected to byKey.values.sortedByDescending { it.postTimeMs } + } + return DeviceNotificationSnapshot( + enabled = enabled, + connected = isConnected, + notifications = entries, + ) + } +} + +class DeviceNotificationListenerService : NotificationListenerService() { + override fun onListenerConnected() { + super.onListenerConnected() + DeviceNotificationStore.setConnected(true) + refreshActiveNotifications() + } + + override fun onListenerDisconnected() { + DeviceNotificationStore.setConnected(false) + super.onListenerDisconnected() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + super.onNotificationPosted(sbn) + val entry = sbn?.toEntry() ?: return + DeviceNotificationStore.upsert(entry) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification?) { + super.onNotificationRemoved(sbn) + val key = sbn?.key ?: return + DeviceNotificationStore.remove(key) + } + + private fun refreshActiveNotifications() { + val entries = + runCatching { + activeNotifications + ?.mapNotNull { it.toEntry() } + ?: emptyList() + }.getOrElse { emptyList() } + DeviceNotificationStore.replace(entries) + } + + private fun StatusBarNotification.toEntry(): DeviceNotificationEntry { + val extras = notification.extras + val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime" + val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE)) + val body = + sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) + ?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT)) + val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) + return DeviceNotificationEntry( + key = keyValue, + packageName = packageName, + title = title, + text = body, + subText = subText, + category = notification.category?.trim()?.ifEmpty { null }, + channelId = notification.channelId?.trim()?.ifEmpty { null }, + postTimeMs = postTime, + isOngoing = isOngoing, + isClearable = isClearable, + ) + } + + companion object { + private fun serviceComponent(context: Context): ComponentName { + return ComponentName(context, DeviceNotificationListenerService::class.java) + } + + fun isAccessEnabled(context: Context): Boolean { + val manager = context.getSystemService(NotificationManager::class.java) ?: return false + return manager.isNotificationListenerAccessGranted(serviceComponent(context)) + } + + fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot { + return DeviceNotificationStore.snapshot(enabled = enabled) + } + + fun requestServiceRebind(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + runCatching { + NotificationListenerService.requestRebind(serviceComponent(context)) + } + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt new file mode 100644 index 000000000000..8d37794df4c9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt @@ -0,0 +1,125 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.android.protocol.OpenClawCanvasCommand +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawDeviceCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand +import ai.openclaw.android.protocol.OpenClawScreenCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand + +enum class InvokeCommandAvailability { + Always, + CameraEnabled, + LocationEnabled, + SmsAvailable, + DebugBuild, +} + +data class InvokeCommandSpec( + val name: String, + val requiresForeground: Boolean = false, + val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always, +) + +object InvokeCommandRegistry { + val all: List = + listOf( + InvokeCommandSpec( + name = OpenClawCanvasCommand.Present.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Hide.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Navigate.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Eval.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Snapshot.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.Push.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.PushJSONL.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.Reset.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawScreenCommand.Record.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCameraCommand.Snap.rawValue, + requiresForeground = true, + availability = InvokeCommandAvailability.CameraEnabled, + ), + InvokeCommandSpec( + name = OpenClawCameraCommand.Clip.rawValue, + requiresForeground = true, + availability = InvokeCommandAvailability.CameraEnabled, + ), + InvokeCommandSpec( + name = OpenClawLocationCommand.Get.rawValue, + availability = InvokeCommandAvailability.LocationEnabled, + ), + InvokeCommandSpec( + name = OpenClawDeviceCommand.Status.rawValue, + ), + InvokeCommandSpec( + name = OpenClawDeviceCommand.Info.rawValue, + ), + InvokeCommandSpec( + name = OpenClawNotificationsCommand.List.rawValue, + ), + InvokeCommandSpec( + name = OpenClawSmsCommand.Send.rawValue, + availability = InvokeCommandAvailability.SmsAvailable, + ), + InvokeCommandSpec( + name = "debug.logs", + availability = InvokeCommandAvailability.DebugBuild, + ), + InvokeCommandSpec( + name = "debug.ed25519", + availability = InvokeCommandAvailability.DebugBuild, + ), + InvokeCommandSpec(name = "app.update"), + ) + + private val byNameInternal: Map = all.associateBy { it.name } + + fun find(command: String): InvokeCommandSpec? = byNameInternal[command] + + fun advertisedCommands( + cameraEnabled: Boolean, + locationEnabled: Boolean, + smsAvailable: Boolean, + debugBuild: Boolean, + ): List { + return all + .filter { spec -> + when (spec.availability) { + InvokeCommandAvailability.Always -> true + InvokeCommandAvailability.CameraEnabled -> cameraEnabled + InvokeCommandAvailability.LocationEnabled -> locationEnabled + InvokeCommandAvailability.SmsAvailable -> smsAvailable + InvokeCommandAvailability.DebugBuild -> debugBuild + } + } + .map { it.name } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index e44896db0fa1..fb88aef03a8d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -4,7 +4,9 @@ import ai.openclaw.android.gateway.GatewaySession import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand import ai.openclaw.android.protocol.OpenClawCanvasCommand import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawDeviceCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand @@ -12,6 +14,8 @@ class InvokeDispatcher( private val canvas: CanvasController, private val cameraHandler: CameraHandler, private val locationHandler: LocationHandler, + private val deviceHandler: DeviceHandler, + private val notificationsHandler: NotificationsHandler, private val screenHandler: ScreenHandler, private val smsHandler: SmsHandler, private val a2uiHandler: A2UIHandler, @@ -20,38 +24,25 @@ class InvokeDispatcher( private val isForeground: () -> Boolean, private val cameraEnabled: () -> Boolean, private val locationEnabled: () -> Boolean, + private val smsAvailable: () -> Boolean, + private val debugBuild: () -> Boolean, + private val onCanvasA2uiPush: () -> Unit, + private val onCanvasA2uiReset: () -> Unit, ) { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { - // Check foreground requirement for canvas/camera/screen commands - if ( - command.startsWith(OpenClawCanvasCommand.NamespacePrefix) || - command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) || - command.startsWith(OpenClawCameraCommand.NamespacePrefix) || - command.startsWith(OpenClawScreenCommand.NamespacePrefix) - ) { - if (!isForeground()) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + val spec = + InvokeCommandRegistry.find(command) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", ) - } - } - - // Check camera enabled - if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) { - return GatewaySession.InvokeResult.error( - code = "CAMERA_DISABLED", - message = "CAMERA_DISABLED: enable Camera in Settings", - ) - } - - // Check location enabled - if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) { + if (spec.requiresForeground && !isForeground()) { return GatewaySession.InvokeResult.error( - code = "LOCATION_DISABLED", - message = "LOCATION_DISABLED: enable Location in Settings", + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", ) } + availabilityError(spec.availability)?.let { return it } return when (command) { // Canvas commands @@ -73,52 +64,33 @@ class InvokeDispatcher( code = "INVALID_REQUEST", message = "INVALID_REQUEST: javaScript required", ) - val result = - try { - canvas.eval(js) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + withCanvasAvailable { + val result = canvas.eval(js) + GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + } } OpenClawCanvasCommand.Snapshot.rawValue -> { val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) - val base64 = - try { + withCanvasAvailable { + val base64 = canvas.snapshotBase64( format = snapshotParams.format, quality = snapshotParams.quality, maxWidth = snapshotParams.maxWidth, ) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + } } // A2UI commands - OpenClawCanvasA2UICommand.Reset.rawValue -> { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) + OpenClawCanvasA2UICommand.Reset.rawValue -> + withReadyA2ui { + withCanvasAvailable { + val res = canvas.eval(A2UIHandler.a2uiResetJS) + onCanvasA2uiReset() + GatewaySession.InvokeResult.ok(res) + } } - val res = canvas.eval(A2UIHandler.a2uiResetJS) - GatewaySession.InvokeResult.ok(res) - } OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { val messages = try { @@ -129,21 +101,14 @@ class InvokeDispatcher( message = err.message ?: "invalid A2UI payload" ) } - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) + withReadyA2ui { + withCanvasAvailable { + val js = A2UIHandler.a2uiApplyMessagesJS(messages) + val res = canvas.eval(js) + onCanvasA2uiPush() + GatewaySession.InvokeResult.ok(res) + } } - val js = A2UIHandler.a2uiApplyMessagesJS(messages) - val res = canvas.eval(js) - GatewaySession.InvokeResult.ok(res) } // Camera commands @@ -153,6 +118,13 @@ class InvokeDispatcher( // Location command OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson) + // Device commands + OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson) + OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson) + + // Notifications command + OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson) + // Screen command OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) @@ -166,11 +138,80 @@ class InvokeDispatcher( // App update "app.update" -> appUpdateHandler.handleUpdate(paramsJson) - else -> - GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: unknown command", - ) + else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command") + } + } + + private suspend fun withReadyA2ui( + block: suspend () -> GatewaySession.InvokeResult, + ): GatewaySession.InvokeResult { + val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + return block() + } + + private suspend fun withCanvasAvailable( + block: suspend () -> GatewaySession.InvokeResult, + ): GatewaySession.InvokeResult { + return try { + block() + } catch (_: Throwable) { + GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) + } + } + + private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? { + return when (availability) { + InvokeCommandAvailability.Always -> null + InvokeCommandAvailability.CameraEnabled -> + if (cameraEnabled()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "CAMERA_DISABLED", + message = "CAMERA_DISABLED: enable Camera in Settings", + ) + } + InvokeCommandAvailability.LocationEnabled -> + if (locationEnabled()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "LOCATION_DISABLED", + message = "LOCATION_DISABLED: enable Location in Settings", + ) + } + InvokeCommandAvailability.SmsAvailable -> + if (smsAvailable()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "SMS_UNAVAILABLE", + message = "SMS_UNAVAILABLE: SMS not available on this device", + ) + } + InvokeCommandAvailability.DebugBuild -> + if (debugBuild()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", + ) + } } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt index 8ba5ad276d5e..c3f463174a4e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt @@ -1,5 +1,6 @@ package ai.openclaw.android.node +import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -37,14 +38,9 @@ fun parseHexColorArgb(raw: String?): Long? { } fun invokeErrorFromThrowable(err: Throwable): Pair { - val raw = (err.message ?: "").trim() - if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error" - - val idx = raw.indexOf(':') - if (idx <= 0) return "UNAVAILABLE" to raw - val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } - val message = raw.substring(idx + 1).trim().ifEmpty { raw } - return code to "$code: $message" + val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error") + val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message + return parsed.code to message } fun normalizeMainKey(raw: String?): String? { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt new file mode 100644 index 000000000000..0216e19208cf --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -0,0 +1,81 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal interface NotificationsStateProvider { + fun readSnapshot(context: Context): DeviceNotificationSnapshot + + fun requestServiceRebind(context: Context) +} + +private object SystemNotificationsStateProvider : NotificationsStateProvider { + override fun readSnapshot(context: Context): DeviceNotificationSnapshot { + val enabled = DeviceNotificationListenerService.isAccessEnabled(context) + if (!enabled) { + return DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ) + } + return DeviceNotificationListenerService.snapshot(context, enabled = true) + } + + override fun requestServiceRebind(context: Context) { + DeviceNotificationListenerService.requestServiceRebind(context) + } +} + +class NotificationsHandler private constructor( + private val appContext: Context, + private val stateProvider: NotificationsStateProvider, +) { + constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider) + + suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { + val snapshot = stateProvider.readSnapshot(appContext) + if (snapshot.enabled && !snapshot.connected) { + stateProvider.requestServiceRebind(appContext) + } + return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot)) + } + + private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String { + return buildJsonObject { + put("enabled", JsonPrimitive(snapshot.enabled)) + put("connected", JsonPrimitive(snapshot.connected)) + put("count", JsonPrimitive(snapshot.notifications.size)) + put( + "notifications", + JsonArray( + snapshot.notifications.map { entry -> + buildJsonObject { + put("key", JsonPrimitive(entry.key)) + put("packageName", JsonPrimitive(entry.packageName)) + put("postTimeMs", JsonPrimitive(entry.postTimeMs)) + put("isOngoing", JsonPrimitive(entry.isOngoing)) + put("isClearable", JsonPrimitive(entry.isClearable)) + entry.title?.let { put("title", JsonPrimitive(it)) } + entry.text?.let { put("text", JsonPrimitive(it)) } + entry.subText?.let { put("subText", JsonPrimitive(it)) } + entry.category?.let { put("category", JsonPrimitive(it)) } + entry.channelId?.let { put("channelId", JsonPrimitive(it)) } + } + }, + ), + ) + }.toString() + } + + companion object { + internal fun forTesting( + appContext: Context, + stateProvider: NotificationsStateProvider, + ): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt index ccca40c4c353..7dd489413318 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt @@ -7,6 +7,7 @@ enum class OpenClawCapability(val rawValue: String) { Sms("sms"), VoiceWake("voiceWake"), Location("location"), + Device("device"), } enum class OpenClawCanvasCommand(val rawValue: String) { @@ -69,3 +70,22 @@ enum class OpenClawLocationCommand(val rawValue: String) { const val NamespacePrefix: String = "location." } } + +enum class OpenClawDeviceCommand(val rawValue: String) { + Status("device.status"), + Info("device.info"), + ; + + companion object { + const val NamespacePrefix: String = "device." + } +} + +enum class OpenClawNotificationsCommand(val rawValue: String) { + List("notifications.list"), + ; + + companion object { + const val NamespacePrefix: String = "notifications." + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt new file mode 100644 index 000000000000..f733d154ed95 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -0,0 +1,150 @@ +package ai.openclaw.android.ui + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import ai.openclaw.android.MainViewModel + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val webViewRef = remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + onDispose { + val webView = webViewRef.value ?: return@onDispose + viewModel.canvas.detach(webView) + webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName) + webView.stopLoading() + webView.destroy() + webViewRef.value = null + } + } + + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.useWideViewPort = false + settings.loadWithOverviewMode = false + settings.builtInZoomControls = false + settings.displayZoomControls = false + settings.setSupportZoom(false) + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e( + "OpenClawWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("OpenClawWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "OpenClawWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "OpenClawWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + + val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) + viewModel.canvas.attach(this) + webViewRef.value = this + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "openclawCanvasA2UIAction" + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt new file mode 100644 index 000000000000..875b82796d3e --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -0,0 +1,493 @@ +package ai.openclaw.android.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class ConnectInputMode { + SetupCode, + Manual, +} + +@Composable +fun ConnectTabScreen(viewModel: MainViewModel) { + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val manualHost by viewModel.manualHost.collectAsState() + val manualPort by viewModel.manualPort.collectAsState() + val manualTls by viewModel.manualTls.collectAsState() + val manualEnabled by viewModel.manualEnabled.collectAsState() + val gatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var advancedOpen by rememberSaveable { mutableStateOf(false) } + var inputMode by + remember(manualEnabled, manualHost, gatewayToken) { + mutableStateOf( + if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) { + ConnectInputMode.Manual + } else { + ConnectInputMode.SetupCode + }, + ) + } + var setupCode by rememberSaveable { mutableStateOf("") } + var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } + var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } + var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } + var passwordInput by rememberSaveable { mutableStateOf("") } + var validationText by rememberSaveable { mutableStateOf(null) } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + style = mobileCallout, + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) { + composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl } + } + + val activeEndpoint = + remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) { + when { + isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!! + inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set" + else -> manualResolvedEndpoint ?: "Not set" + } + } + + val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway" + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Gateway Connection", style = mobileTitle1, color = mobileText) + Text( + "One primary action. Open advanced controls only when needed.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = mobileText) + } + } + + Button( + onClick = { + if (isConnected) { + viewModel.disconnect() + validationText = null + return@Button + } + if (statusText.contains("operator offline", ignoreCase = true)) { + validationText = null + viewModel.refreshGatewayConnection() + return@Button + } + + val config = + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + + validationText = null + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (isConnected) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + onClick = { advancedOpen = !advancedOpen }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced controls", style = mobileHeadline, color = mobileText) + Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls", + tint = mobileTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MethodChip( + label = "Setup Code", + active = inputMode == ConnectInputMode.SetupCode, + onClick = { inputMode = ConnectInputMode.SetupCode }, + ) + MethodChip( + label = "Manual", + active = inputMode == ConnectInputMode.Manual, + onClick = { inputMode = ConnectInputMode.Manual }, + ) + } + + Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + + if (inputMode == ConnectInputMode.SetupCode) { + Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = { + setupCode = it + validationText = null + }, + placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + if (!setupResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = setupResolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip( + label = "Android Emulator", + onClick = { + manualHostInput = "10.0.2.2" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + QuickFillChip( + label = "Localhost", + onClick = { + manualHostInput = "127.0.0.1" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + } + + Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualHostInput, + onValueChange = { + manualHostInput = it + validationText = null + }, + placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualPortInput, + onValueChange = { + manualPortInput = it + validationText = null + }, + placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = mobileHeadline, color = mobileText) + Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary) + } + Switch( + checked = manualTlsInput, + onCheckedChange = { + manualTlsInput = it + validationText = null + }, + colors = + SwitchDefaults.colors( + checkedTrackColor = mobileAccent, + uncheckedTrackColor = mobileBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = { viewModel.setGatewayToken(it) }, + placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = manualResolvedEndpoint) + } + } + + HorizontalDivider(color = mobileBorder) + + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { + Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + } + } + } + + if (!validationText.isNullOrBlank()) { + Text(validationText!!, style = mobileCaption1, color = mobileWarning) + } + } +} + +@Composable +private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) mobileAccent else mobileSurface, + contentColor = if (active) Color.White else mobileText, + ), + border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold)) + } +} + +@Composable +private fun QuickFillChip(label: String, onClick: () -> Unit) { + Button( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccentSoft, + contentColor = mobileAccent, + ), + elevation = null, + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun CommandBlock(command: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A))) + Text( + text = command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = mobileCodeText, + ) + } + } +} + +@Composable +private fun EndpointPreview(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = mobileBorder) + Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText) + HorizontalDivider(color = mobileBorder) + } +} + +@Composable +private fun outlinedColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt new file mode 100644 index 000000000000..4421a82be4b6 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -0,0 +1,142 @@ +package ai.openclaw.android.ui + +import androidx.core.net.toUri +import java.util.Base64 +import java.util.Locale +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject + +internal data class GatewayEndpointConfig( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +internal data class GatewaySetupCode( + val url: String, + val token: String?, + val password: String?, +) + +internal data class GatewayConnectConfig( + val host: String, + val port: Int, + val tls: Boolean, + val token: String, + val password: String, +) + +private val gatewaySetupJson = Json { ignoreUnknownKeys = true } + +internal fun resolveGatewayConnectConfig( + useSetupCode: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + fallbackToken: String, + fallbackPassword: String, +): GatewayConnectConfig? { + if (useSetupCode) { + val setup = decodeGatewaySetupCode(setupCode) ?: return null + val parsed = parseGatewayEndpoint(setup.url) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = setup.token ?: fallbackToken.trim(), + password = setup.password ?: fallbackPassword.trim(), + ) + } + + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null + val parsed = parseGatewayEndpoint(manualUrl) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = fallbackToken.trim(), + password = fallbackPassword.trim(), + ) +} + +internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8) + val obj = parseJsonObject(decoded) ?: return null + val url = jsonField(obj, "url").orEmpty() + if (url.isEmpty()) return null + val token = jsonField(obj, "token") + val password = jsonField(obj, "password") + GatewaySetupCode(url = url, token = token, password = password) + } catch (_: IllegalArgumentException) { + null + } +} + +internal fun resolveScannedSetupCode(rawInput: String): String? { + val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null + return setupCode.takeIf { decodeGatewaySetupCode(it) != null } +} + +internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} + +private fun parseJsonObject(input: String): JsonObject? { + return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull() +} + +private fun resolveSetupCodeCandidate(rawInput: String): String? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") } + return qrSetupCode ?: trimmed +} + +private fun jsonField(obj: JsonObject, key: String): String? { + val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + return value.ifEmpty { null } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt new file mode 100644 index 000000000000..eb4f95775e72 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt @@ -0,0 +1,106 @@ +package ai.openclaw.android.ui + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ai.openclaw.android.R + +internal val mobileBackgroundGradient = + Brush.verticalGradient( + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ), + ) + +internal val mobileSurface = Color(0xFFF6F7FA) +internal val mobileSurfaceStrong = Color(0xFFECEEF3) +internal val mobileBorder = Color(0xFFE5E7EC) +internal val mobileBorderStrong = Color(0xFFD6DAE2) +internal val mobileText = Color(0xFF17181C) +internal val mobileTextSecondary = Color(0xFF5D6472) +internal val mobileTextTertiary = Color(0xFF99A0AE) +internal val mobileAccent = Color(0xFF1D5DD8) +internal val mobileAccentSoft = Color(0xFFECF3FF) +internal val mobileSuccess = Color(0xFF2F8C5A) +internal val mobileSuccessSoft = Color(0xFFEEF9F3) +internal val mobileWarning = Color(0xFFC8841A) +internal val mobileWarningSoft = Color(0xFFFFF8EC) +internal val mobileDanger = Color(0xFFD04B4B) +internal val mobileDangerSoft = Color(0xFFFFF2F2) +internal val mobileCodeBg = Color(0xFF15171B) +internal val mobileCodeText = Color(0xFFE8EAEE) + +internal val mobileFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +internal val mobileTitle1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +internal val mobileTitle2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = (-0.3).sp, + ) + +internal val mobileHeadline = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +internal val mobileBody = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +internal val mobileCallout = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +internal val mobileCaption1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +internal val mobileCaption2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt new file mode 100644 index 000000000000..4c9e064e6afb --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -0,0 +1,1209 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import ai.openclaw.android.LocationMode +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.R +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +private enum class OnboardingStep(val index: Int, val label: String) { + Welcome(1, "Welcome"), + Gateway(2, "Gateway"), + Permissions(3, "Permissions"), + FinalCheck(4, "Connect"), +} + +private enum class GatewayInputMode { + SetupCode, + Manual, +} + +private val onboardingBackgroundGradient = + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ) +private val onboardingSurface = Color(0xFFF6F7FA) +private val onboardingBorder = Color(0xFFE5E7EC) +private val onboardingBorderStrong = Color(0xFFD6DAE2) +private val onboardingText = Color(0xFF17181C) +private val onboardingTextSecondary = Color(0xFF4D5563) +private val onboardingTextTertiary = Color(0xFF8A92A2) +private val onboardingAccent = Color(0xFF1D5DD8) +private val onboardingAccentSoft = Color(0xFFECF3FF) +private val onboardingSuccess = Color(0xFF2F8C5A) +private val onboardingWarning = Color(0xFFC8841A) +private val onboardingCommandBg = Color(0xFF15171B) +private val onboardingCommandBorder = Color(0xFF2B2E35) +private val onboardingCommandAccent = Color(0xFF3FC97A) +private val onboardingCommandText = Color(0xFFE8EAEE) + +private val onboardingFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +private val onboardingDisplayStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.8).sp, + ) + +private val onboardingTitle1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +private val onboardingHeadlineStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +private val onboardingBodyStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +private val onboardingCalloutStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +private val onboardingCaption1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +private val onboardingCaption2Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) + +@Composable +fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val serverName by viewModel.serverName.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val persistedGatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } + var setupCode by rememberSaveable { mutableStateOf("") } + var gatewayUrl by rememberSaveable { mutableStateOf("") } + var gatewayPassword by rememberSaveable { mutableStateOf("") } + var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) } + var gatewayAdvancedOpen by rememberSaveable { mutableStateOf(false) } + var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") } + var manualPort by rememberSaveable { mutableStateOf("18789") } + var manualTls by rememberSaveable { mutableStateOf(false) } + var gatewayError by rememberSaveable { mutableStateOf(null) } + var attemptedConnect by rememberSaveable { mutableStateOf(false) } + + var enableDiscovery by rememberSaveable { mutableStateOf(true) } + var enableNotifications by rememberSaveable { mutableStateOf(true) } + var enableMicrophone by rememberSaveable { mutableStateOf(false) } + var enableCamera by rememberSaveable { mutableStateOf(false) } + var enableSms by rememberSaveable { mutableStateOf(false) } + + val smsAvailable = + remember(context) { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + val selectedPermissions = + remember( + context, + enableDiscovery, + enableNotifications, + enableMicrophone, + enableCamera, + enableSms, + smsAvailable, + ) { + val requested = mutableListOf() + if (enableDiscovery) { + requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + } + if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS + if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO + if (enableCamera) requested += Manifest.permission.CAMERA + if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS + requested.filterNot { isPermissionGranted(context, it) } + } + + val enabledPermissionSummary = + remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) { + val enabled = mutableListOf() + if (enableDiscovery) enabled += "Gateway discovery" + if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications" + if (enableMicrophone) enabled += "Microphone" + if (enableCamera) enabled += "Camera" + if (smsAvailable && enableSms) enabled += "SMS" + if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + step = OnboardingStep.FinalCheck + } + + val qrScanLauncher = + rememberLauncherForActivityResult(ScanContract()) { result -> + val contents = result.contents?.trim().orEmpty() + if (contents.isEmpty()) { + return@rememberLauncherForActivityResult + } + val scannedSetupCode = resolveScannedSetupCode(contents) + if (scannedSetupCode == null) { + gatewayError = "QR code did not contain a valid setup code." + return@rememberLauncherForActivityResult + } + setupCode = scannedSetupCode + gatewayInputMode = GatewayInputMode.SetupCode + gatewayError = null + attemptedConnect = false + } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Brush.verticalGradient(onboardingBackgroundGradient)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "FIRST RUN", + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), + color = onboardingAccent, + ) + Text( + "OpenClaw\nMobile Setup", + style = onboardingDisplayStyle.copy(lineHeight = 38.sp), + color = onboardingText, + ) + Text( + "Step ${step.index} of 4", + style = onboardingCaption1Style, + color = onboardingAccent, + ) + } + StepRailWrap(current = step) + + when (step) { + OnboardingStep.Welcome -> WelcomeStep() + OnboardingStep.Gateway -> + GatewayStep( + inputMode = gatewayInputMode, + advancedOpen = gatewayAdvancedOpen, + setupCode = setupCode, + manualHost = manualHost, + manualPort = manualPort, + manualTls = manualTls, + gatewayToken = persistedGatewayToken, + gatewayPassword = gatewayPassword, + gatewayError = gatewayError, + onScanQrClick = { + gatewayError = null + qrScanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan OpenClaw onboarding QR") + setBeepEnabled(false) + setOrientationLocked(false) + }, + ) + }, + onAdvancedOpenChange = { gatewayAdvancedOpen = it }, + onInputModeChange = { + gatewayInputMode = it + gatewayError = null + }, + onSetupCodeChange = { + setupCode = it + gatewayError = null + }, + onManualHostChange = { + manualHost = it + gatewayError = null + }, + onManualPortChange = { + manualPort = it + gatewayError = null + }, + onManualTlsChange = { manualTls = it }, + onTokenChange = viewModel::setGatewayToken, + onPasswordChange = { gatewayPassword = it }, + ) + OnboardingStep.Permissions -> + PermissionsStep( + enableDiscovery = enableDiscovery, + enableNotifications = enableNotifications, + enableMicrophone = enableMicrophone, + enableCamera = enableCamera, + enableSms = enableSms, + smsAvailable = smsAvailable, + context = context, + onDiscoveryChange = { enableDiscovery = it }, + onNotificationsChange = { enableNotifications = it }, + onMicrophoneChange = { enableMicrophone = it }, + onCameraChange = { enableCamera = it }, + onSmsChange = { enableSms = it }, + ) + OnboardingStep.FinalCheck -> + FinalStep( + parsedGateway = parseGatewayEndpoint(gatewayUrl), + statusText = statusText, + isConnected = isConnected, + serverName = serverName, + remoteAddress = remoteAddress, + attemptedConnect = attemptedConnect, + enabledPermissions = enabledPermissionSummary, + methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "QR / Setup Code" else "Manual", + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val backEnabled = step != OnboardingStep.Welcome + Surface( + modifier = Modifier.size(52.dp), + shape = RoundedCornerShape(14.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, if (backEnabled) onboardingBorderStrong else onboardingBorder), + ) { + IconButton( + onClick = { + step = + when (step) { + OnboardingStep.Welcome -> OnboardingStep.Welcome + OnboardingStep.Gateway -> OnboardingStep.Welcome + OnboardingStep.Permissions -> OnboardingStep.Gateway + OnboardingStep.FinalCheck -> OnboardingStep.Permissions + } + }, + enabled = backEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (backEnabled) onboardingTextSecondary else onboardingTextTertiary, + ) + } + } + + when (step) { + OnboardingStep.Welcome -> { + Button( + onClick = { step = OnboardingStep.Gateway }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Gateway -> { + Button( + onClick = { + if (gatewayInputMode == GatewayInputMode.SetupCode) { + val parsedSetup = decodeGatewaySetupCode(setupCode) + if (parsedSetup == null) { + gatewayError = "Scan QR code first, or use Advanced setup." + return@Button + } + val parsedGateway = parseGatewayEndpoint(parsedSetup.url) + if (parsedGateway == null) { + gatewayError = "Setup code has invalid gateway URL." + return@Button + } + gatewayUrl = parsedSetup.url + parsedSetup.token?.let { viewModel.setGatewayToken(it) } + gatewayPassword = parsedSetup.password.orEmpty() + } else { + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) + val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) + if (parsedGateway == null) { + gatewayError = "Manual endpoint is invalid." + return@Button + } + gatewayUrl = parsedGateway.displayUrl + } + step = OnboardingStep.Permissions + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Permissions -> { + Button( + onClick = { + viewModel.setCameraEnabled(enableCamera) + viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off) + if (selectedPermissions.isEmpty()) { + step = OnboardingStep.FinalCheck + } else { + permissionLauncher.launch(selectedPermissions.toTypedArray()) + } + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.FinalCheck -> { + if (isConnected) { + Button( + onClick = { viewModel.setOnboardingCompleted(true) }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } else { + Button( + onClick = { + val parsed = parseGatewayEndpoint(gatewayUrl) + if (parsed == null) { + step = OnboardingStep.Gateway + gatewayError = "Invalid gateway URL." + return@Button + } + val token = persistedGatewayToken.trim() + val password = gatewayPassword.trim() + attemptedConnect = true + viewModel.setManualEnabled(true) + viewModel.setManualHost(parsed.host) + viewModel.setManualPort(parsed.port) + viewModel.setManualTls(parsed.tls) + if (token.isNotEmpty()) { + viewModel.setGatewayToken(token) + } + viewModel.setGatewayPassword(password) + viewModel.connectManual() + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + } + } + } + } + } +} + +@Composable +private fun StepRailWrap(current: OnboardingStep) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(color = onboardingBorder) + StepRail(current = current) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepRail(current: OnboardingStep) { + val steps = OnboardingStep.entries + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + steps.forEach { step -> + val complete = step.index < current.index + val active = step.index == current.index + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + color = + when { + complete -> onboardingSuccess + active -> onboardingAccent + else -> onboardingBorder + }, + shape = RoundedCornerShape(999.dp), + ), + ) + Text( + text = step.label, + style = onboardingCaption2Style.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) onboardingAccent else onboardingTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun WelcomeStep() { + StepShell(title = "What You Get") { + Bullet("Control the gateway and operator chat from one mobile surface.") + Bullet("Connect with setup code and recover pairing with CLI commands.") + Bullet("Enable only the permissions and capabilities you want.") + Bullet("Finish with a real connection check before entering the app.") + } +} + +@Composable +private fun GatewayStep( + inputMode: GatewayInputMode, + advancedOpen: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + gatewayToken: String, + gatewayPassword: String, + gatewayError: String?, + onScanQrClick: () -> Unit, + onAdvancedOpenChange: (Boolean) -> Unit, + onInputModeChange: (GatewayInputMode) -> Unit, + onSetupCodeChange: (String) -> Unit, + onManualHostChange: (String) -> Unit, + onManualPortChange: (String) -> Unit, + onManualTlsChange: (Boolean) -> Unit, + onTokenChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, +) { + val resolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } } + + StepShell(title = "Gateway Connection") { + GuideBlock(title = "Scan onboarding QR") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr") + Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + Button( + onClick = onScanQrClick, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + ), + ) { + Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + if (!resolvedEndpoint.isNullOrBlank()) { + Text("QR captured. Review endpoint below.", style = onboardingCalloutStyle, color = onboardingSuccess) + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorderStrong), + onClick = { onAdvancedOpenChange(!advancedOpen) }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText) + Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced setup" else "Expand advanced setup", + tint = onboardingTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GuideBlock(title = "Manual setup commands") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + Text( + "`--json` prints `setupCode` and `gatewayUrl`.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + Text( + "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + } + GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange) + + if (inputMode == GatewayInputMode.SetupCode) { + Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = onSetupCodeChange, + placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + if (!resolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip(label = "Android Emulator", onClick = { + onManualHostChange("10.0.2.2") + onManualPortChange("18789") + onManualTlsChange(false) + }) + QuickFillChip(label = "Localhost", onClick = { + onManualHostChange("127.0.0.1") + onManualPortChange("18789") + onManualTlsChange(false) + }) + } + + Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualHost, + onValueChange = onManualHostChange, + placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualPort, + onValueChange = onManualPortChange, + placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText) + Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + } + Switch( + checked = manualTls, + onCheckedChange = onManualTlsChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = onTokenChange, + placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayPassword, + onValueChange = onPasswordChange, + placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = manualResolvedEndpoint) + } + } + } + } + + if (!gatewayError.isNullOrBlank()) { + Text(gatewayError, color = onboardingWarning, style = onboardingCaption1Style) + } + } +} + +@Composable +private fun GuideBlock( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(onboardingAccent.copy(alpha = 0.4f))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + content() + } + } +} + +@Composable +private fun GatewayModeToggle( + inputMode: GatewayInputMode, + onInputModeChange: (GatewayInputMode) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + GatewayModeChip( + label = "Setup Code", + active = inputMode == GatewayInputMode.SetupCode, + onClick = { onInputModeChange(GatewayInputMode.SetupCode) }, + modifier = Modifier.weight(1f), + ) + GatewayModeChip( + label = "Manual", + active = inputMode == GatewayInputMode.Manual, + onClick = { onInputModeChange(GatewayInputMode.Manual) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun GatewayModeChip( + label: String, + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) onboardingAccent else onboardingSurface, + contentColor = if (active) Color.White else onboardingText, + ), + border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong), + ) { + Text( + text = label, + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), + ) + } +} + +@Composable +private fun QuickFillChip( + label: String, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + colors = + ButtonDefaults.textButtonColors( + containerColor = onboardingAccentSoft, + contentColor = onboardingAccent, + ), + ) { + Text(label, style = onboardingCaption1Style.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun ResolvedEndpoint(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = onboardingBorder) + Text( + "RESOLVED ENDPOINT", + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.7.sp), + color = onboardingTextSecondary, + ) + Text( + endpoint, + style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace), + color = onboardingText, + ) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepShell( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + HorizontalDivider(color = onboardingBorder) + Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = onboardingTitle1Style, color = onboardingText) + content() + } + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun InlineDivider() { + HorizontalDivider(color = onboardingBorder) +} + +@Composable +private fun PermissionsStep( + enableDiscovery: Boolean, + enableNotifications: Boolean, + enableMicrophone: Boolean, + enableCamera: Boolean, + enableSms: Boolean, + smsAvailable: Boolean, + context: Context, + onDiscoveryChange: (Boolean) -> Unit, + onNotificationsChange: (Boolean) -> Unit, + onMicrophoneChange: (Boolean) -> Unit, + onCameraChange: (Boolean) -> Unit, + onSmsChange: (Boolean) -> Unit, +) { + val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + StepShell(title = "Permissions") { + Text( + "Enable only what you need now. You can change everything later in Settings.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + PermissionToggleRow( + title = "Gateway discovery", + subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)", + checked = enableDiscovery, + granted = isPermissionGranted(context, discoveryPermission), + onCheckedChange = onDiscoveryChange, + ) + InlineDivider() + if (Build.VERSION.SDK_INT >= 33) { + PermissionToggleRow( + title = "Notifications", + subtitle = "Foreground service + alerts", + checked = enableNotifications, + granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), + onCheckedChange = onNotificationsChange, + ) + InlineDivider() + } + PermissionToggleRow( + title = "Microphone", + subtitle = "Voice tab transcription", + checked = enableMicrophone, + granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), + onCheckedChange = onMicrophoneChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Camera", + subtitle = "camera.snap and camera.clip", + checked = enableCamera, + granted = isPermissionGranted(context, Manifest.permission.CAMERA), + onCheckedChange = onCameraChange, + ) + if (smsAvailable) { + InlineDivider() + PermissionToggleRow( + title = "SMS", + subtitle = "Allow gateway-triggered SMS sending", + checked = enableSms, + granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), + onCheckedChange = onSmsChange, + ) + } + Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } +} + +@Composable +private fun PermissionToggleRow( + title: String, + subtitle: String, + checked: Boolean, + granted: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + Text( + if (granted) "Granted" else "Not granted", + style = onboardingCaption1Style, + color = if (granted) onboardingSuccess else onboardingTextSecondary, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } +} + +@Composable +private fun FinalStep( + parsedGateway: GatewayEndpointConfig?, + statusText: String, + isConnected: Boolean, + serverName: String?, + remoteAddress: String?, + attemptedConnect: Boolean, + enabledPermissions: String, + methodLabel: String, +) { + StepShell(title = "Review") { + SummaryField(label = "Method", value = methodLabel) + SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL") + SummaryField(label = "Enabled Permissions", value = enabledPermissions) + + if (!attemptedConnect) { + Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } else { + Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) + if (isConnected) { + Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) + } else { + GuideBlock(title = "Pairing Required") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw nodes pending") + CommandBlock("openclaw nodes approve ") + Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + } + } + } +} + +@Composable +private fun SummaryField(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + label, + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = onboardingTextSecondary, + ) + Text(value, style = onboardingHeadlineStyle, color = onboardingText) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun CommandBlock(command: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(onboardingCommandBg, RoundedCornerShape(12.dp)) + .border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)), + ) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) + Text( + command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = onboardingCalloutStyle, + fontFamily = FontFamily.Monospace, + color = onboardingCommandText, + ) + } +} + +@Composable +private fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier + .padding(top = 7.dp) + .size(8.dp) + .background(onboardingAccentSoft, CircleShape), + ) + Box( + modifier = + Modifier + .padding(top = 9.dp) + .size(4.dp) + .background(onboardingAccent, CircleShape), + ) + Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f)) + } +} + +private fun isPermissionGranted(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt new file mode 100644 index 000000000000..1345d8e3cb9f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,320 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class HomeTab( + val label: String, + val icon: ImageVector, +) { + Connect(label = "Connect", icon = Icons.Default.CheckCircle), + Chat(label = "Chat", icon = Icons.Default.ChatBubble), + Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver), + Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare), + Settings(label = "Settings", icon = Icons.Default.Settings), +} + +private enum class StatusVisual { + Connected, + Connecting, + Warning, + Error, + Offline, +} + +@Composable +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + val statusVisual = + remember(statusText, isConnected) { + val lower = statusText.lowercase() + when { + isConnected -> StatusVisual.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting + lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning + lower.contains("error") || lower.contains("failed") -> StatusVisual.Error + else -> StatusVisual.Offline + } + } + + val density = LocalDensity.current + val imeVisible = WindowInsets.ime.getBottom(density) > 0 + val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + if (!hideBottomTabBar) { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + } + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val isConnected by viewModel.isConnected.collectAsState() + val isNodeConnected by viewModel.isNodeConnected.collectAsState() + val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() + val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() + val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() + val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() + val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true + val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) + val restoreCtaText = + when { + canvasRehydratePending -> "Restore requested. Waiting for agent…" + !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! + else -> "Canvas reset. Tap to restore dashboard." + } + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + + if (showRestoreCta) { + Surface( + onClick = { + if (canvasRehydratePending) return@Surface + viewModel.requestCanvasRehydrate(source = "screen_tab_cta") + }, + modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), + shape = RoundedCornerShape(12.dp), + color = mobileSurface.copy(alpha = 0.9f), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 4.dp, + ) { + Text( + text = restoreCtaText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontWeight = FontWeight.Medium), + color = mobileText, + ) + } + } + } +} + +@Composable +private fun TopStatusBar( + statusText: String, + statusVisual: StatusVisual, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + + val (chipBg, chipDot, chipText, chipBorder) = + when (statusVisual) { + StatusVisual.Connected -> + listOf( + mobileSuccessSoft, + mobileSuccess, + mobileSuccess, + Color(0xFFCFEBD8), + ) + StatusVisual.Connecting -> + listOf( + mobileAccentSoft, + mobileAccent, + mobileAccent, + Color(0xFFD5E2FA), + ) + StatusVisual.Warning -> + listOf( + mobileWarningSoft, + mobileWarning, + mobileWarning, + Color(0xFFEED8B8), + ) + StatusVisual.Error -> + listOf( + mobileDangerSoft, + mobileDanger, + mobileDanger, + Color(0xFFF3C8C8), + ) + StatusVisual.Offline -> + listOf( + mobileSurface, + mobileTextTertiary, + mobileTextSecondary, + mobileBorder, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets), + color = Color.Transparent, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OpenClaw", + style = mobileTitle2, + color = mobileText, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = chipBg, + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.padding(top = 1.dp), + color = chipDot, + shape = RoundedCornerShape(999.dp), + ) { + Box(modifier = Modifier.padding(4.dp)) + } + Text( + text = statusText.trim().ifEmpty { "Offline" }, + style = mobileCaption1, + color = chipText, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun BottomTabBar( + activeTab: HomeTab, + onSelect: (HomeTab) -> Unit, +) { + val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + + Box( + modifier = + Modifier + .fillMaxWidth(), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.White.copy(alpha = 0.97f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 6.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(safeInsets) + .padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeTab.entries.forEach { tab -> + val active = tab == activeTab + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f).heightIn(min = 58.dp), + shape = RoundedCornerShape(16.dp), + color = if (active) mobileAccentSoft else Color.Transparent, + border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = if (active) mobileAccent else mobileTextTertiary, + ) + Text( + text = tab.label, + color = if (active) mobileAccent else mobileTextSecondary, + style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium), + ) + } + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt index af0cfe628ac0..e50a03cc5bf7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -1,429 +1,20 @@ package ai.openclaw.android.ui -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import ai.openclaw.android.CameraHudKind import ai.openclaw.android.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } + val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } + if (!onboardingCompleted) { + OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + return } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "OpenClawWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("OpenClawWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "OpenClawWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "OpenClawWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "openclawCanvasA2UIAction" - } + PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index bb04c30108ce..6de3151a7f19 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -9,10 +9,12 @@ import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,29 +25,27 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,51 +53,33 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import ai.openclaw.android.BuildConfig import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel -import ai.openclaw.android.NodeForegroundService -import ai.openclaw.android.VoiceWakeMode -import ai.openclaw.android.WakeWords @Composable fun SettingsSheet(viewModel: MainViewModel) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val instanceId by viewModel.instanceId.collectAsState() val displayName by viewModel.displayName.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState() val locationMode by viewModel.locationMode.collectAsState() val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val preventSleep by viewModel.preventSleep.collectAsState() - val wakeWords by viewModel.wakeWords.collectAsState() - val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val isConnected by viewModel.isConnected.collectAsState() - val manualEnabled by viewModel.manualEnabled.collectAsState() - val manualHost by viewModel.manualHost.collectAsState() - val manualPort by viewModel.manualPort.collectAsState() - val manualTls by viewModel.manualTls.collectAsState() - val gatewayToken by viewModel.gatewayToken.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() - val gateways by viewModel.gateways.collectAsState() - val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() - val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val listState = rememberLazyListState() - val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } - val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = remember { listOfNotNull(Build.MANUFACTURER, Build.MODEL) @@ -114,39 +96,14 @@ fun SettingsSheet(viewModel: MainViewModel) { versionName } } - - if (pendingTrust != null) { - val prompt = pendingTrust!! - AlertDialog( - onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, - title = { Text("Trust this gateway?") }, - text = { - Text( - "First-time TLS connection.\n\n" + - "Verify this SHA-256 fingerprint out-of-band before trusting:\n" + - prompt.fingerprintSha256, - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { - Text("Trust and connect") - } - }, - dismissButton = { - TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { - Text("Cancel") - } - }, + val listItemColors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = mobileText, + supportingColor = mobileTextSecondary, + trailingIconColor = mobileTextSecondary, + leadingIconColor = mobileTextSecondary, ) - } - - LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } - val commitWakeWords = { - val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) - if (parsed != null) { - viewModel.setWakeWords(parsed) - } - } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> @@ -189,9 +146,16 @@ fun SettingsSheet(viewModel: MainViewModel) { } } + var micPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED, + ) + } val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> - // Status text is handled by NodeRuntime. + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + micPermissionGranted = granted } val smsPermissionAvailable = @@ -211,6 +175,22 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.refreshGatewayConnection() } + DisposableEffect(lifecycleOwner, context) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + micPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + smsPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + fun setCameraEnabledChecked(checked: Boolean) { if (!checked) { viewModel.setCameraEnabled(false) @@ -268,324 +248,152 @@ fun SettingsSheet(viewModel: MainViewModel) { } } - val visibleGateways = - if (isConnected && remoteAddress != null) { - gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } - } else { - gateways - } - - val gatewayDiscoveryFooterText = - if (visibleGateways.isEmpty()) { - discoveryStatusText - } else if (isConnected) { - "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" - } else { - "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" - } - - LazyColumn( - state = listState, + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .fillMaxSize() + .background(mobileBackgroundGradient), ) { - // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. - item { Text("Node", style = MaterialTheme.typography.titleSmall) } + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "SETTINGS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Device Configuration", style = mobileTitle2, color = mobileText) + Text( + "Manage capabilities, permissions, and diagnostics.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } + item { HorizontalDivider(color = mobileBorder) } + + // Order parity: Node → Voice → Camera → Messaging → Location → Screen. + item { + Text( + "NODE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { OutlinedTextField( value = displayName, onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } + item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } + item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } - // Gateway - item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } - item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } - if (serverName != null) { - item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } - } - if (remoteAddress != null) { - item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } - } - item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - ) { - Text("Disconnect") - } - } - } - - item { HorizontalDivider() } - - if (!isConnected || visibleGateways.isNotEmpty()) { + // Voice item { Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } - } else { - items(items = visibleGateways, key = { it.stableId }) { gateway -> - val detailLines = - buildList { - add("IP: ${gateway.host}:${gateway.port}") - gateway.lanHost?.let { add("LAN: $it") } - gateway.tailnetDns?.let { add("Tailnet: $it") } - if (gateway.gatewayPort != null || gateway.canvasPort != null) { - val gw = (gateway.gatewayPort ?: gateway.port).toString() - val canvas = gateway.canvasPort?.toString() ?: "—" - add("Ports: gw $gw · canvas $canvas") - } - } - ListItem( - headlineContent = { Text(gateway.name) }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - detailLines.forEach { line -> - Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Microphone permission", style = mobileHeadline) }, + supportingContent = { + Text( + if (micPermissionGranted) { + "Granted. Use the Voice tab mic button to capture transcript." + } else { + "Required for Voice tab transcription." + }, + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (micPermissionGranted) { + openAppSettings(context) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - } - }, - trailingContent = { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connect(gateway) - }, - ) { - Text("Connect") - } - }, - ) - } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (micPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) } item { Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.", + style = mobileCallout, + color = mobileTextSecondary, ) } - } - - item { HorizontalDivider() } - item { - ListItem( - headlineContent = { Text("Advanced") }, - supportingContent = { Text("Manual gateway connection") }, - trailingContent = { - Icon( - imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (advancedExpanded) "Collapse" else "Expand", - ) - }, - modifier = - Modifier.clickable { - setAdvancedExpanded(!advancedExpanded) - }, - ) - } - item { - AnimatedVisibility(visible = advancedExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Use Manual Gateway") }, - supportingContent = { Text("Use this when discovery is blocked.") }, - trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, - ) - - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = gatewayToken, - onValueChange = viewModel::setGatewayToken, - label = { Text("Gateway Token") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - singleLine = true, - ) - ListItem( - headlineContent = { Text("Require TLS") }, - supportingContent = { Text("Pin the gateway certificate on first connect.") }, - trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), - ) - - val hostOk = manualHost.trim().isNotEmpty() - val portOk = manualPort in 1..65535 - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled && hostOk && portOk, - ) { - Text("Connect (Manual)") - } - } - } - } - - item { HorizontalDivider() } - - // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } - item { - val enabled = voiceWakeMode != VoiceWakeMode.Off - ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, - trailingContent = { - Switch( - checked = enabled, - onCheckedChange = { on -> - if (on) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - } else { - viewModel.setVoiceWakeMode(VoiceWakeMode.Off) - } - }, - ) - }, - ) - } - item { - AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Foreground Only") }, - supportingContent = { Text("Listens only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Foreground, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Always, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Always) - }, - ) - }, - ) - } - } - } - item { - OutlinedTextField( - value = wakeWordsText, - onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, - modifier = - Modifier.fillMaxWidth().onFocusChanged { focusState -> - if (focusState.isFocused) { - wakeWordsHadFocus = true - } else if (wakeWordsHadFocus) { - wakeWordsHadFocus = false - commitWakeWords() - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - commitWakeWords() - focusManager.clearFocus() - }, - ), - ) - } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } - item { - Text( - if (isConnected) { - "Any node can edit wake words. Changes sync via the gateway." - } else { - "Connect to a gateway to sync wake words globally." - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Camera - item { Text("Camera", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "CAMERA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Allow Camera") }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Allow Camera", style = mobileHeadline) }, + supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, ) } item { Text( "Tip: grant Microphone permission for video clips with audio.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Messaging - item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "MESSAGING", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { val buttonLabel = when { @@ -594,7 +402,9 @@ fun SettingsSheet(viewModel: MainViewModel) { else -> "Grant" } ListItem( - headlineContent = { Text("SMS Permission") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("SMS Permission", style = mobileHeadline) }, supportingContent = { Text( if (smsPermissionAvailable) { @@ -602,6 +412,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "SMS requires a device with telephony hardware." }, + style = mobileCallout, ) }, trailingContent = { @@ -615,91 +426,125 @@ fun SettingsSheet(viewModel: MainViewModel) { } }, enabled = smsPermissionAvailable, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text(buttonLabel) + Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Location - item { Text("Location", style = MaterialTheme.typography.titleSmall) } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Off") }, - supportingContent = { Text("Disable location sharing.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Off, - onClick = { viewModel.setLocationMode(LocationMode.Off) }, - ) - }, - ) - ListItem( - headlineContent = { Text("While Using") }, - supportingContent = { Text("Only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.WhileUsing, - onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Allow background location (requires system permission).") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Always, - onClick = { requestLocationPermissions(LocationMode.Always) }, - ) - }, + item { + Text( + "LOCATION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - } - item { - ListItem( - headlineContent = { Text("Precise Location") }, - supportingContent = { Text("Use precise GPS when available.") }, - trailingContent = { - Switch( - checked = locationPreciseEnabled, - onCheckedChange = ::setPreciseLocationChecked, - enabled = locationMode != LocationMode.Off, + item { + Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Off", style = mobileHeadline) }, + supportingContent = { Text("Disable location sharing.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, ) - }, - ) - } + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("While Using", style = mobileHeadline) }, + supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Precise Location", style = mobileHeadline) }, + supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + } item { Text( "Always may require Android Settings to allow background location.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Screen - item { Text("Screen", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "SCREEN", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Prevent Sleep") }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Debug - item { Text("Debug", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "DEBUG", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Debug Canvas Status") }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, + supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, trailingContent = { Switch( checked = canvasDebugStatusEnabled, @@ -709,10 +554,47 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } - item { Spacer(modifier = Modifier.height(20.dp)) } + item { Spacer(modifier = Modifier.height(24.dp)) } + } } } +@Composable +private fun settingsTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +private fun settingsRowModifier() = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) + .background(Color.White, RoundedCornerShape(14.dp)) + +@Composable +private fun settingsPrimaryButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + +@Composable +private fun settingsDangerButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileDanger, + contentColor = Color.White, + disabledContainerColor = mobileDanger.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + private fun openAppSettings(context: Context) { val intent = Intent( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt deleted file mode 100644 index d608fc38a7bb..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun StatusPill( - gateway: GatewayState, - voiceEnabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - activity: StatusActivity? = null, -) { - Surface( - onClick = onClick, - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = overlayContainerColor(), - tonalElevation = 3.dp, - shadowElevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(9.dp), - shape = CircleShape, - color = gateway.color, - ) {} - - Text( - text = gateway.title, - style = MaterialTheme.typography.labelLarge, - ) - } - - VerticalDivider( - modifier = Modifier.height(14.dp).alpha(0.35f), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (activity != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = activity.icon, - contentDescription = activity.contentDescription, - tint = activity.tint ?: overlayIconColor(), - modifier = Modifier.size(18.dp), - ) - Text( - text = activity.title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - ) - } - } else { - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -data class StatusActivity( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val tint: Color? = null, -) - -enum class GatewayState(val title: String, val color: Color) { - Connected("Connected", Color(0xFF2ECC71)), - Connecting("Connecting…", Color(0xFFF1C40F)), - Error("Error", Color(0xFFE74C3C)), - Disconnected("Offline", Color(0xFF9E9E9E)), -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt new file mode 100644 index 000000000000..9149a0f08864 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt @@ -0,0 +1,451 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.voice.VoiceConversationEntry +import ai.openclaw.android.voice.VoiceConversationRole +import kotlin.math.PI +import kotlin.math.max +import kotlin.math.sin + +@Composable +fun VoiceTabScreen(viewModel: MainViewModel) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val activity = remember(context) { context.findActivity() } + val listState = rememberLazyListState() + + val isConnected by viewModel.isConnected.collectAsState() + val gatewayStatus by viewModel.statusText.collectAsState() + val micEnabled by viewModel.micEnabled.collectAsState() + val micStatusText by viewModel.micStatusText.collectAsState() + val micLiveTranscript by viewModel.micLiveTranscript.collectAsState() + val micQueuedMessages by viewModel.micQueuedMessages.collectAsState() + val micConversation by viewModel.micConversation.collectAsState() + val micInputLevel by viewModel.micInputLevel.collectAsState() + val micIsSending by viewModel.micIsSending.collectAsState() + + val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming } + val showThinkingBubble = micIsSending && !hasStreamingAssistant + + var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) } + var pendingMicEnable by remember { mutableStateOf(false) } + + DisposableEffect(lifecycleOwner, context) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasMicPermission = context.hasRecordAudioPermission() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val requestMicPermission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + hasMicPermission = granted + if (granted && pendingMicEnable) { + viewModel.setMicEnabled(true) + } + pendingMicEnable = false + } + + LaunchedEffect(micConversation.size, showThinkingBubble) { + val total = micConversation.size + if (showThinkingBubble) 1 else 0 + if (total > 0) { + listState.animateScrollToItem(total - 1) + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(mobileBackgroundGradient) + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Voice mode", style = mobileTitle2, color = mobileText) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong, + border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong), + ) { + Text( + if (isConnected) "Connected" else "Offline", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = mobileCaption1, + color = if (isConnected) mobileAccent else mobileTextSecondary, + ) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (micConversation.isEmpty() && !showThinkingBubble) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Tap the mic and speak. Each pause sends a turn automatically.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } + } + + items(items = micConversation, key = { it.id }) { entry -> + VoiceTurnBubble(entry = entry) + } + + if (showThinkingBubble) { + item { + VoiceThinkingBubble() + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + val queueCount = micQueuedMessages.size + val stateText = + when { + queueCount > 0 -> "$queueCount queued" + micIsSending -> "Sending" + micEnabled -> "Listening" + else -> "Mic off" + } + Text( + "$gatewayStatus · $stateText", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + style = mobileCaption1, + color = mobileTextSecondary, + ) + } + + if (!micLiveTranscript.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)), + ) { + Text( + micLiveTranscript!!.trim(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout, + color = mobileText, + ) + } + } + + MicWaveform(level = micInputLevel, active = micEnabled) + + Button( + onClick = { + if (micEnabled) { + viewModel.setMicEnabled(false) + return@Button + } + if (hasMicPermission) { + viewModel.setMicEnabled(true) + } else { + pendingMicEnable = true + requestMicPermission.launch(Manifest.permission.RECORD_AUDIO) + } + }, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(86.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (micEnabled) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on", + modifier = Modifier.size(30.dp), + ) + } + + Text( + if (micEnabled) "Tap to stop" else "Tap to speak", + style = mobileCallout, + color = mobileTextSecondary, + ) + + if (!hasMicPermission) { + val showRationale = + if (activity == null) { + false + } else { + ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO) + } + Text( + if (showRationale) { + "Microphone permission is required for voice mode." + } else { + "Microphone blocked. Open app settings to enable it." + }, + style = mobileCaption1, + color = mobileWarning, + textAlign = TextAlign.Center, + ) + Button( + onClick = { openAppSettings(context) }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText), + ) { + Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold)) + } + } + + Text( + micStatusText, + style = mobileCaption1, + color = mobileTextTertiary, + ) + } + } + } +} + +@Composable +private fun VoiceTurnBubble(entry: VoiceConversationEntry) { + val isUser = entry.role == VoiceConversationRole.User + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.90f), + shape = RoundedCornerShape(14.dp), + color = if (isUser) mobileAccentSoft else mobileSurface, + border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + if (isUser) "You" else "OpenClaw", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Text( + if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text, + style = mobileCallout, + color = mobileText, + ) + } + } + } +} + +@Composable +private fun VoiceThinkingBubble() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + modifier = Modifier.fillMaxWidth(0.68f), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ThinkingDots(color = mobileTextSecondary) + Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary) + } + } + } +} + +@Composable +private fun ThinkingDots(color: Color) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + ThinkingDot(alpha = 0.38f, color = color) + ThinkingDot(alpha = 0.62f, color = color) + ThinkingDot(alpha = 0.90f, color = color) + } +} + +@Composable +private fun ThinkingDot(alpha: Float, color: Color) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = color, + ) {} +} + +@Composable +private fun MicWaveform(level: Float, active: Boolean) { + val transition = rememberInfiniteTransition(label = "voiceWave") + val phase by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart), + label = "voiceWavePhase", + ) + + val effective = if (active) level.coerceIn(0f, 1f) else 0f + val base = max(effective, if (active) 0.05f else 0f) + + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(16) { index -> + val pulse = + if (!active) { + 0f + } else { + ((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat() + } + val barHeight = 6.dp + (24.dp * (base * pulse)) + Box( + modifier = + Modifier + .width(5.dp) + .height(barHeight) + .background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)), + ) + } + } +} + +private fun Context.hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) +} + +private fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt index 07ba769697df..22099500ebf1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt @@ -1,31 +1,36 @@ package ai.openclaw.android.ui.chat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,149 +42,168 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileSurface +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileTextTertiary @Composable fun ChatComposer( - sessionKey: String, - sessions: List, - mainSessionKey: String, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, - errorText: String?, attachments: List, onPickImages: () -> Unit, onRemoveAttachment: (id: String) -> Unit, onSetThinkingLevel: (level: String) -> Unit, - onSelectSession: (sessionKey: String) -> Unit, onRefresh: () -> Unit, onAbort: () -> Unit, onSend: (text: String) -> Unit, ) { var input by rememberSaveable { mutableStateOf("") } var showThinkingMenu by remember { mutableStateOf(false) } - var showSessionMenu by remember { mutableStateOf(false) } - - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = friendlySessionName( - sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey - ) val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + val sendBusy = pendingRunCount > 0 - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - FilledTonalButton( - onClick = { showSessionMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = "Thinking: ${thinkingLabel(thinkingLevel)}", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) } + } - DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { - for (entry in sessionOptions) { - DropdownMenuItem( - text = { Text(friendlySessionName(entry.displayName ?: entry.key)) }, - onClick = { - onSelectSession(entry.key) - showSessionMenu = false - }, - trailingIcon = { - if (entry.key == sessionKey) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) - } - } + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } } + } - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1) - } + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + onClick = onPickImages, + ) + } - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } + HorizontalDivider(color = mobileBorder) - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") - } - } + Text( + text = "MESSAGE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), + color = mobileTextSecondary, + ) - if (attachments.isNotEmpty()) { - AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) - } + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(92.dp), + placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + minLines = 2, + maxLines = 5, + textStyle = mobileBodyStyle().copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = chatTextFieldColors(), + ) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message OpenClaw…") }, - minLines = 2, - maxLines = 6, + if (!healthOk) { + Text( + text = "Gateway is offline. Connect first in the Connect tab.", + style = mobileCallout, + color = ai.openclaw.android.ui.mobileWarning, ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + compact = true, + onClick = onRefresh, + ) - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + compact = true, + onClick = onAbort, + ) + } - if (pendingRunCount > 0) { - FilledTonalIconButton( - onClick = onAbort, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = Color(0x33E74C3C), - contentColor = Color(0xFFE74C3C), - ), - ) { - Icon(Icons.Default.Stop, contentDescription = "Abort") - } + Button( + onClick = { + val text = input + input = "" + onSend(text) + }, + enabled = canSend, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileBorderStrong, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong), + ) { + if (sendBusy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White) } else { - FilledTonalIconButton(onClick = { - val text = input - input = "" - onSend(text) - }, enabled = canSend) { - Icon(Icons.Default.ArrowUpward, contentDescription = "Send") - } + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) } - } - - if (!errorText.isNullOrBlank()) { + Spacer(modifier = Modifier.width(8.dp)) Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, + text = "Send", + style = mobileHeadline.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -187,26 +211,35 @@ fun ChatComposer( } @Composable -private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, +private fun SecondaryActionButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + enabled: Boolean, + compact: Boolean = false, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = if (compact) Modifier.size(44.dp) else Modifier.height(44.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileTextSecondary, + disabledContainerColor = Color.White, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, mobileBorderStrong), + contentPadding = if (compact) PaddingValues(0.dp) else ButtonDefaults.ContentPadding, ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionLabel, style = MaterialTheme.typography.labelSmall) + Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp)) + if (!compact) { + Spacer(modifier = Modifier.width(5.dp)) Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = label, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = if (enabled) mobileTextSecondary else mobileTextTertiary, ) } } @@ -220,14 +253,14 @@ private fun ThinkingMenuItem( onDismiss: () -> Unit, ) { DropdownMenuItem( - text = { Text(thinkingLabel(value)) }, + text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) }, onClick = { onSet(value) onDismiss() }, trailingIcon = { if (value == current.trim().lowercase()) { - Text("✓") + Text("✓", style = mobileCallout, color = mobileAccent) } else { Spacer(modifier = Modifier.width(10.dp)) } @@ -266,20 +299,55 @@ private fun AttachmentsStrip( private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { Surface( shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) - FilledTonalIconButton( + Text( + text = fileName, + style = mobileCaption1, + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( onClick = onRemove, - modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(999.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), ) { - Text("×") + Text( + text = "×", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold), + color = mobileTextSecondary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) } } } } + +@Composable +private fun chatTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +@Composable +private fun mobileBodyStyle() = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = ai.openclaw.android.ui.mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt index 77dba2275a41..e121212529a9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -3,12 +3,21 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,167 +25,534 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileTextSecondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.commonmark.Extension +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.ext.task.list.items.TaskListItemMarker +import org.commonmark.ext.task.list.items.TaskListItemsExtension +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.Heading +import org.commonmark.node.HardLineBreak +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image as MarkdownImage +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text as MarkdownTextNode +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +private const val LIST_INDENT_DP = 14 +private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$") + +private val markdownParser: Parser by lazy { + val extensions: List = + listOf( + AutolinkExtension.create(), + StrikethroughExtension.create(), + TablesExtension.create(), + TaskListItemsExtension.create(), + ) + Parser.builder() + .extensions(extensions) + .build() +} @Composable fun ChatMarkdown(text: String, textColor: Color) { - val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + val document = remember(text) { markdownParser.parse(text) as Document } + val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (b in blocks) { - when (b) { - is ChatMarkdownBlock.Text -> { - val trimmed = b.text.trimEnd() - if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, - color = textColor, - ) + RenderMarkdownBlocks( + start = document.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = 0, + ) + } +} + +@Composable +private fun RenderMarkdownBlocks( + start: Node?, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var node = start + while (node != null) { + val current = node + when (current) { + is Paragraph -> { + RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) + } + is Heading -> { + val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } + Text( + text = headingText, + style = headingStyle(current.level), + color = textColor, + ) + } + is FencedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null }) + } + } + is IndentedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = null) } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) + } + is BlockQuote -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(mobileTextSecondary.copy(alpha = 0.35f)), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RenderMarkdownBlocks( + start = current.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) } } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + is BulletList -> { + RenderBulletList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is OrderedList -> { + RenderOrderedList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is TableBlock -> { + RenderTableBlock( + table = current, + textColor = textColor, + inlineStyles = inlineStyles, + ) + } + is ThematicBreak -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(mobileTextSecondary.copy(alpha = 0.25f)), + ) + } + is HtmlBlock -> { + val literal = current.literal.orEmpty().trim() + if (literal.isNotEmpty()) { + Text( + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) } } } + node = current.next } } -private sealed interface ChatMarkdownBlock { - data class Text(val text: String) : ChatMarkdownBlock - data class Code(val code: String, val language: String?) : ChatMarkdownBlock - data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock -} +@Composable +private fun RenderParagraph( + paragraph: Paragraph, + textColor: Color, + inlineStyles: InlineStyles, +) { + val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) } + if (standaloneImage != null) { + InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType) + return + } -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() + val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) } + if (annotated.text.trimEnd().isEmpty()) { + return + } - val out = ArrayList() - var idx = 0 - while (idx < raw.length) { - val fenceStart = raw.indexOf("```", startIndex = idx) - if (fenceStart < 0) { - out.addAll(splitInlineImages(raw.substring(idx))) - break - } + Text( + text = annotated, + style = mobileCallout, + color = textColor, + ) +} - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) +@Composable +private fun RenderBulletList( + list: BulletList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "•", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + item = item.next } + } +} - val langLineStart = fenceStart + 3 - val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } - val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } - - val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd - val fenceEnd = raw.indexOf("```", startIndex = codeStart) - if (fenceEnd < 0) { - out.addAll(splitInlineImages(raw.substring(fenceStart))) - break +@Composable +private fun RenderOrderedList( + list: OrderedList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var index = list.markerStartNumber ?: 1 + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "$index.", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + index += 1 + } + item = item.next } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) + } +} - idx = fenceEnd + 3 +@Composable +private fun RenderListItem( + item: ListItem, + markerText: String, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var contentStart = item.firstChild + var marker = markerText + val task = contentStart as? TaskListItemMarker + if (task != null) { + marker = if (task.isChecked) "☑" else "☐" + contentStart = task.next } - return out + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = marker, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + modifier = Modifier.width(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + RenderMarkdownBlocks( + start = contentStart, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth + 1, + ) + } + } } -private fun splitInlineImages(text: String): List { - if (text.isEmpty()) return emptyList() - val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") - val out = ArrayList() +@Composable +private fun RenderTableBlock( + table: TableBlock, + textColor: Color, + inlineStyles: InlineStyles, +) { + val rows = remember(table) { buildTableRows(table, inlineStyles) } + if (rows.isEmpty()) return + + val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1) + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)), + ) { + for (row in rows) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + for (index in 0 until maxCols) { + val cell = row.cells.getOrNull(index) ?: AnnotatedString("") + Text( + text = cell, + style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout, + color = textColor, + modifier = Modifier + .border(1.dp, mobileTextSecondary.copy(alpha = 0.22f)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .width(160.dp), + ) + } + } + } + } +} - var idx = 0 - while (idx < text.length) { - val m = regex.find(text, startIndex = idx) ?: break - val start = m.range.first - val end = m.range.last + 1 - if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) +private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var child = table.firstChild + while (child != null) { + when (child) { + is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles)) + is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles)) + is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles)) + } + child = child.next + } + return rows +} - val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") - val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() - if (b64.isNotEmpty()) { - out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) +private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var row = section.firstChild + while (row != null) { + if (row is TableRow) { + rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles)) } - idx = end + row = row.next } + return rows +} - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out +private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow { + val cells = mutableListOf() + var cellNode = row.firstChild + while (cellNode != null) { + if (cellNode is TableCell) { + cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles)) + } + cellNode = cellNode.next + } + return TableRenderRow(isHeader = isHeader, cells = cells) } -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") +private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString { + return buildAnnotatedString { + appendInlineNode( + node = start, + inlineCodeBg = inlineStyles.inlineCodeBg, + inlineCodeColor = inlineStyles.inlineCodeColor, + ) + } +} - val out = buildAnnotatedString { - var i = 0 - while (i < text.length) { - if (text.startsWith("**", startIndex = i)) { - val end = text.indexOf("**", startIndex = i + 2) - if (end > i + 2) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - continue +private fun AnnotatedString.Builder.appendInlineNode( + node: Node?, + inlineCodeBg: Color, + inlineCodeColor: Color, +) { + var current = node + while (current != null) { + when (current) { + is MarkdownTextNode -> append(current.literal) + is SoftLineBreak -> append('\n') + is HardLineBreak -> append('\n') + is Code -> { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + color = inlineCodeColor, + ), + ) { + append(current.literal) } } - - if (text[i] == '`') { - val end = text.indexOf('`', startIndex = i + 1) - if (end > i + 1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = inlineCodeBg, - ), - ) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is Emphasis -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } - - if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { - val end = text.indexOf('*', startIndex = i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is StrongEmphasis -> { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Strikethrough -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Link -> { + withStyle( + SpanStyle( + color = mobileAccent, + textDecoration = TextDecoration.Underline, + ), + ) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } + is MarkdownImage -> { + val alt = buildPlainText(current.firstChild) + if (alt.isNotBlank()) { + append(alt) + } else { + append("image") + } + } + is HtmlInline -> { + if (!current.literal.isNullOrBlank()) { + append(current.literal) + } + } + else -> { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + current = current.next + } +} - append(text[i]) - i += 1 +private fun buildPlainText(start: Node?): String { + val sb = StringBuilder() + var node = start + while (node != null) { + when (node) { + is MarkdownTextNode -> sb.append(node.literal) + is SoftLineBreak, is HardLineBreak -> sb.append('\n') + else -> sb.append(buildPlainText(node.firstChild)) } + node = node.next + } + return sb.toString() +} + +private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? { + val only = paragraph.firstChild as? MarkdownImage ?: return null + if (only.next != null) return null + return parseDataImageDestination(only.destination) +} + +private fun parseDataImageDestination(destination: String?): ParsedDataImage? { + val raw = destination?.trim().orEmpty() + if (raw.isEmpty()) return null + val match = dataImageRegex.matchEntire(raw) ?: return null + val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png" + val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (base64.isEmpty()) return null + return ParsedDataImage(mimeType = "image/$subtype", base64 = base64) +} + +private fun headingStyle(level: Int): TextStyle { + return when (level.coerceIn(1, 6)) { + 1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + 2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + 3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold) + 4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold) + else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold) } - return out } +private data class InlineStyles( + val inlineCodeBg: Color, + val inlineCodeColor: Color, +) + +private data class TableRenderRow( + val isHeader: Boolean, + val cells: List, +) + +private data class ParsedDataImage( + val mimeType: String, + val base64: String, +) + @Composable private fun InlineBase64Image(base64: String, mimeType: String?) { var image by remember(base64) { mutableStateOf(null) } @@ -208,8 +584,8 @@ private fun InlineBase64Image(base64: String, mimeType: String?) { Text( text = "Image unavailable", modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCaption1, + color = mobileTextSecondary, ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt index bcec19a5fa25..889de006cb45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt @@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary @Composable fun ChatMessageListCard( @@ -29,6 +29,7 @@ fun ChatMessageListCard( pendingRunCount: Int, pendingToolCalls: List, streamingAssistantText: String?, + healthOk: Boolean, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -38,73 +39,70 @@ fun ChatMessageListCard( listState.animateScrollToItem(index = 0) } - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), - ) { - // With reverseLayout = true, index 0 renders at the BOTTOM. - // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp), + ) { + // With reverseLayout = true, index 0 renders at the BOTTOM. + // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - val stream = streamingAssistantText?.trim() - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) - } - } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) } + } - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) } + } - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> + ChatMessageBubble(message = messages[messages.size - 1 - idx]) } } + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk) + } } } @Composable -private fun EmptyChatHint(modifier: Modifier = Modifier) { - Row( - modifier = modifier.alpha(0.7f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder), ) { - Icon( - imageVector = Icons.Default.ArrowCircleDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Message OpenClaw…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("No messages yet", style = mobileHeadline, color = mobileText) + Text( + text = + if (healthOk) { + "Send the first prompt to start this session." + } else { + "Connect gateway first, then return to chat." + }, + style = mobileCallout, + color = mobileTextSecondary, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt index bf2943275517..3f4250c3dbbb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt @@ -2,7 +2,8 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,55 +24,93 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.foundation.Image +import androidx.compose.ui.unit.sp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatMessageContent import ai.openclaw.android.chat.ChatPendingToolCall import ai.openclaw.android.tools.ToolDisplayRegistry +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalContext + +private data class ChatBubbleStyle( + val alignEnd: Boolean, + val containerColor: Color, + val borderColor: Color, + val roleColor: Color, +) @Composable fun ChatMessageBubble(message: ChatMessage) { - val isUser = message.role.lowercase() == "user" + val role = message.role.trim().lowercase(Locale.US) + val style = bubbleStyle(role) - // Filter to only displayable content parts (text with content, or base64 images) - val displayableContent = message.content.filter { part -> - when (part.type) { - "text" -> !part.text.isNullOrBlank() - else -> part.base64 != null + // Filter to only displayable content parts (text with content, or base64 images). + val displayableContent = + message.content.filter { part -> + when (part.type) { + "text" -> !part.text.isNullOrBlank() + else -> part.base64 != null + } } - } - // Skip rendering entirely if no displayable content if (displayableContent.isEmpty()) return + ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) { + ChatMessageBody(content = displayableContent, textColor = mobileText) + } +} + +@Composable +private fun ChatBubbleContainer( + style: ChatBubbleStyle, + roleLabel: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start, ) { Surface( - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, style.borderColor), + color = style.containerColor, tonalElevation = 0.dp, shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), + modifier = Modifier.fillMaxWidth(0.90f), ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), + Column( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = displayableContent, textColor = textColor) + Text( + text = roleLabel, + style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = style.roleColor, + ) + content() } } } @@ -80,7 +118,7 @@ fun ChatMessageBubble(message: ChatMessage) { @Composable private fun ChatMessageBody(content: List, textColor: Color) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (part in content) { when (part.type) { "text" -> { @@ -98,19 +136,16 @@ private fun ChatMessageBody(content: List, textColor: Color) @Composable fun ChatTypingIndicatorBubble() { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = roleLabel("assistant"), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DotPulse() - Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + DotPulse(color = mobileTextSecondary) + Text("Thinking...", style = mobileCallout, color = mobileTextSecondary) } } } @@ -122,38 +157,37 @@ fun ChatPendingToolsBubble(toolCalls: List) { remember(toolCalls, context) { toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) - for (display in displays.take(6)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = "TOOLS", + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + for (display in displays.take(6)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "${display.emoji} ${display.label}", + style = mobileCallout, + color = mobileTextSecondary, + fontFamily = FontFamily.Monospace, + ) + display.detailLine?.let { detail -> Text( - "${display.emoji} ${display.label}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + detail, + style = mobileCaption1, + color = mobileTextSecondary, fontFamily = FontFamily.Monospace, ) - display.detailLine?.let { detail -> - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - } } } - if (toolCalls.size > 6) { - Text( - "… +${toolCalls.size - 6} more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + if (toolCalls.size > 6) { + Text( + text = "... +${toolCalls.size - 6} more", + style = mobileCaption1, + color = mobileTextSecondary, + ) } } } @@ -161,37 +195,47 @@ fun ChatPendingToolsBubble(toolCalls: List) { @Composable fun ChatStreamingAssistantBubble(text: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) - } - } + ChatBubbleContainer( + style = bubbleStyle("assistant").copy(borderColor = mobileAccent), + roleLabel = "ASSISTANT · LIVE", + ) { + ChatMarkdown(text = text, textColor = mobileText) } } -@Composable -private fun bubbleBackground(isUser: Boolean): Brush { - return if (isUser) { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), - ) - } else { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), - ) +private fun bubbleStyle(role: String): ChatBubbleStyle { + return when (role) { + "user" -> + ChatBubbleStyle( + alignEnd = true, + containerColor = mobileAccentSoft, + borderColor = mobileAccent, + roleColor = mobileAccent, + ) + + "system" -> + ChatBubbleStyle( + alignEnd = false, + containerColor = mobileWarningSoft, + borderColor = mobileWarning.copy(alpha = 0.45f), + roleColor = mobileWarning, + ) + + else -> + ChatBubbleStyle( + alignEnd = false, + containerColor = Color.White, + borderColor = mobileBorderStrong, + roleColor = mobileTextSecondary, + ) } } -@Composable -private fun textColorOverBubble(isUser: Boolean): Color { - return if (isUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface +private fun roleLabel(role: String): String { + return when (role) { + "user" -> "USER" + "system" -> "SYSTEM" + else -> "ASSISTANT" } } @@ -216,48 +260,64 @@ private fun ChatBase64Image(base64: String, mimeType: String?) { } if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "attachment", - contentScale = ContentScale.Fit, + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, mobileBorder), + color = Color.White, modifier = Modifier.fillMaxWidth(), - ) + ) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } } else if (failed) { - Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary) } } @Composable -private fun DotPulse() { +private fun DotPulse(color: Color) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f) - PulseDot(alpha = 0.62f) - PulseDot(alpha = 0.90f) + PulseDot(alpha = 0.38f, color = color) + PulseDot(alpha = 0.62f, color = color) + PulseDot(alpha = 0.90f, color = color) } } @Composable -private fun PulseDot(alpha: Float) { +private fun PulseDot(alpha: Float, color: Color) { Surface( modifier = Modifier.size(6.dp).alpha(alpha), shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = color, ) {} } @Composable fun ChatCodeBlock(code: String, language: String?) { Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = RoundedCornerShape(8.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = code.trimEnd(), - modifier = Modifier.padding(10.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!language.isNullOrBlank()) { + Text( + text = language.uppercase(Locale.US), + style = mobileCaption2.copy(letterSpacing = 0.4.sp), + color = mobileTextSecondary, + ) + } + Text( + text = code.trimEnd(), + fontFamily = FontFamily.Monospace, + style = mobileCallout, + color = mobileCodeText, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt deleted file mode 100644 index 56b5cfb1faf6..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry - -@Composable -fun ChatSessionsDialog( - currentSessionKey: String, - sessions: List, - onDismiss: () -> Unit, - onRefresh: () -> Unit, - onSelect: (sessionKey: String) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = {}, - title = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Sessions", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onRefresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - }, - text = { - if (sessions.isEmpty()) { - Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sessions, key = { it.key }) { entry -> - SessionRow( - entry = entry, - isCurrent = entry.key == currentSessionKey, - onClick = { onSelect(entry.key) }, - ) - } - } - } - }, - ) -} - -@Composable -private fun SessionRow( - entry: ChatSessionEntry, - isCurrent: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = - if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { - Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt index effee6708e0d..12e13ab365ab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt @@ -5,10 +5,19 @@ import android.net.Uri import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -17,10 +26,28 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.openclaw.android.MainViewModel +import ai.openclaw.android.chat.ChatSessionEntry import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileSuccess +import ai.openclaw.android.ui.mobileSuccessSoft +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -72,52 +99,160 @@ fun ChatSheetContent(viewModel: MainViewModel) { modifier = Modifier .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + ChatThreadSelector( + sessionKey = sessionKey, + sessions = sessions, + mainSessionKey = mainSessionKey, + healthOk = healthOk, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + ) + + if (!errorText.isNullOrBlank()) { + ChatErrorRail(errorText = errorText!!) + } + ChatMessageListCard( messages = messages, pendingRunCount = pendingRunCount, pendingToolCalls = pendingToolCalls, streamingAssistantText = streamingAssistantText, + healthOk = healthOk, modifier = Modifier.weight(1f, fill = true), ) - ChatComposer( - sessionKey = sessionKey, - sessions = sessions, - mainSessionKey = mainSessionKey, - healthOk = healthOk, - thinkingLevel = thinkingLevel, - pendingRunCount = pendingRunCount, - errorText = errorText, - attachments = attachments, - onPickImages = { pickImages.launch("image/*") }, - onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, - onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onSelectSession = { key -> viewModel.switchChatSession(key) }, - onRefresh = { - viewModel.refreshChat() - viewModel.refreshChatSessions(limit = 200) - }, - onAbort = { viewModel.abortChat() }, - onSend = { text -> - val outgoing = - attachments.map { att -> - OutgoingAttachment( - type = "image", - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ) - } - viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) - attachments.clear() - }, + Row(modifier = Modifier.fillMaxWidth().imePadding()) { + ChatComposer( + healthOk = healthOk, + thinkingLevel = thinkingLevel, + pendingRunCount = pendingRunCount, + attachments = attachments, + onPickImages = { pickImages.launch("image/*") }, + onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, + onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, + onRefresh = { + viewModel.refreshChat() + viewModel.refreshChatSessions(limit = 200) + }, + onAbort = { viewModel.abortChat() }, + onSend = { text -> + val outgoing = + attachments.map { att -> + OutgoingAttachment( + type = "image", + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ) + } + viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) + attachments.clear() + }, + ) + } + } +} + +@Composable +private fun ChatThreadSelector( + sessionKey: String, + sessions: List, + mainSessionKey: String, + healthOk: Boolean, + onSelectSession: (String) -> Unit, +) { + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val currentSessionLabel = + friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = "SESSION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = mobileTextSecondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = currentSessionLabel, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ChatConnectionPill(healthOk = healthOk) + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ChatConnectionPill(healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, + border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), + ) { + Text( + text = if (healthOk) "Connected" else "Offline", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = if (healthOk) mobileSuccess else mobileWarning, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), ) } } +@Composable +private fun ChatErrorRail(errorText: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = androidx.compose.ui.graphics.Color.White, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "CHAT ERROR", + style = mobileCaption2.copy(letterSpacing = 0.6.sp), + color = mobileDanger, + ) + Text(text = errorText, style = mobileCallout, color = mobileText) + } + } +} + data class PendingImageAttachment( val id: String, val fileName: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt new file mode 100644 index 000000000000..c28e523a1821 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt @@ -0,0 +1,523 @@ +package ai.openclaw.android.voice + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.core.content.ContextCompat +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +enum class VoiceConversationRole { + User, + Assistant, +} + +data class VoiceConversationEntry( + val id: String, + val role: VoiceConversationRole, + val text: String, + val isStreaming: Boolean = false, +) + +class MicCaptureManager( + private val context: Context, + private val scope: CoroutineScope, + private val sendToGateway: suspend (String) -> String?, +) { + companion object { + private const val speechMinSessionMs = 30_000L + private const val speechCompleteSilenceMs = 1_500L + private const val speechPossibleSilenceMs = 900L + private const val maxConversationEntries = 40 + private const val pendingRunTimeoutMs = 45_000L + } + + private data class QueuedUtterance( + val text: String, + ) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val json = Json { ignoreUnknownKeys = true } + + private val _micEnabled = MutableStateFlow(false) + val micEnabled: StateFlow = _micEnabled + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _statusText = MutableStateFlow("Mic off") + val statusText: StateFlow = _statusText + + private val _liveTranscript = MutableStateFlow(null) + val liveTranscript: StateFlow = _liveTranscript + + private val _queuedMessages = MutableStateFlow>(emptyList()) + val queuedMessages: StateFlow> = _queuedMessages + + private val _conversation = MutableStateFlow>(emptyList()) + val conversation: StateFlow> = _conversation + + private val _inputLevel = MutableStateFlow(0f) + val inputLevel: StateFlow = _inputLevel + + private val _isSending = MutableStateFlow(false) + val isSending: StateFlow = _isSending + + private val messageQueue = ArrayDeque() + private val sessionSegments = mutableListOf() + private var lastFinalSegment: String? = null + private var pendingRunId: String? = null + private var pendingAssistantEntryId: String? = null + private var gatewayConnected = false + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var pendingRunTimeoutJob: Job? = null + private var stopRequested = false + + fun setMicEnabled(enabled: Boolean) { + if (_micEnabled.value == enabled) return + _micEnabled.value = enabled + if (enabled) { + start() + sendQueuedIfIdle() + } else { + stop() + flushSessionToQueue() + sendQueuedIfIdle() + } + } + + fun onGatewayConnectionChanged(connected: Boolean) { + gatewayConnected = connected + if (connected) { + sendQueuedIfIdle() + return + } + if (messageQueue.isNotEmpty()) { + _statusText.value = queuedWaitingStatus() + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event != "chat") return + if (payloadJson.isNullOrBlank()) return + val payload = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + + val runId = pendingRunId ?: return + val eventRunId = payload["runId"].asStringOrNull() ?: return + if (eventRunId != runId) return + + when (payload["state"].asStringOrNull()) { + "delta" -> { + val deltaText = parseAssistantText(payload) + if (!deltaText.isNullOrBlank()) { + upsertPendingAssistant(text = deltaText.trim(), isStreaming = true) + } + } + "final" -> { + val finalText = parseAssistantText(payload)?.trim().orEmpty() + if (finalText.isNotEmpty()) { + upsertPendingAssistant(text = finalText, isStreaming = false) + } else if (pendingAssistantEntryId != null) { + updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false) + } + completePendingTurn() + } + "error" -> { + val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" } + upsertPendingAssistant(text = errorMessage, isStreaming = false) + completePendingTurn() + } + "aborted" -> { + upsertPendingAssistant(text = "Response aborted", isStreaming = false) + completePendingTurn() + } + } + } + + private fun start() { + stopRequested = false + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _statusText.value = "Speech recognizer unavailable" + _micEnabled.value = false + return + } + if (!hasMicPermission()) { + _statusText.value = "Microphone permission required" + _micEnabled.value = false + return + } + + mainHandler.post { + try { + if (recognizer == null) { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + } + startListeningSession() + } catch (err: Throwable) { + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + _micEnabled.value = false + } + } + } + + private fun stop() { + stopRequested = true + restartJob?.cancel() + restartJob = null + _isListening.value = false + _statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off" + _inputLevel.value = 0f + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun startListeningSession() { + val recognizerInstance = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs) + putExtra( + RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, + speechPossibleSilenceMs, + ) + } + _statusText.value = + when { + _isSending.value -> "Listening · sending queued voice" + messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued" + else -> "Listening" + } + _isListening.value = true + recognizerInstance.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 300L) { + if (stopRequested) return + if (!_micEnabled.value) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested || !_micEnabled.value) return@post + try { + startListeningSession() + } catch (_: Throwable) { + // retry through onError + } + } + } + } + + private fun flushSessionToQueue() { + val message = sessionSegments.joinToString(" ").trim() + sessionSegments.clear() + _liveTranscript.value = null + lastFinalSegment = null + if (message.isEmpty()) return + + appendConversation( + role = VoiceConversationRole.User, + text = message, + ) + messageQueue.addLast(QueuedUtterance(text = message)) + publishQueue() + } + + private fun publishQueue() { + _queuedMessages.value = messageQueue.map { it.text } + } + + private fun sendQueuedIfIdle() { + if (_isSending.value) return + if (messageQueue.isEmpty()) { + if (_micEnabled.value) { + _statusText.value = "Listening" + } else { + _statusText.value = "Mic off" + } + return + } + if (!gatewayConnected) { + _statusText.value = queuedWaitingStatus() + return + } + + val next = messageQueue.first() + _isSending.value = true + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + _statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice" + + scope.launch { + try { + val runId = sendToGateway(next.text) + pendingRunId = runId + if (runId == null) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + messageQueue.removeFirst() + publishQueue() + _isSending.value = false + pendingAssistantEntryId = null + sendQueuedIfIdle() + } else { + armPendingRunTimeout(runId) + } + } catch (err: Throwable) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + _isSending.value = false + pendingRunId = null + pendingAssistantEntryId = null + _statusText.value = + if (!gatewayConnected) { + queuedWaitingStatus() + } else { + "Send failed: ${err.message ?: err::class.simpleName}" + } + } + } + } + + private fun armPendingRunTimeout(runId: String) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = + scope.launch { + delay(pendingRunTimeoutMs) + if (pendingRunId != runId) return@launch + pendingRunId = null + pendingAssistantEntryId = null + _isSending.value = false + _statusText.value = + if (gatewayConnected) { + "Voice reply timed out; retrying queued turn" + } else { + queuedWaitingStatus() + } + sendQueuedIfIdle() + } + } + + private fun completePendingTurn() { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + if (messageQueue.isNotEmpty()) { + messageQueue.removeFirst() + publishQueue() + } + pendingRunId = null + pendingAssistantEntryId = null + _isSending.value = false + sendQueuedIfIdle() + } + + private fun queuedWaitingStatus(): String { + return "${messageQueue.size} queued · waiting for gateway" + } + + private fun appendConversation( + role: VoiceConversationRole, + text: String, + isStreaming: Boolean = false, + ): String { + val id = UUID.randomUUID().toString() + _conversation.value = + (_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming)) + .takeLast(maxConversationEntries) + return id + } + + private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) { + val current = _conversation.value + _conversation.value = + current.map { entry -> + if (entry.id == id) { + val updatedText = text ?: entry.text + entry.copy(text = updatedText, isStreaming = isStreaming) + } else { + entry + } + } + } + + private fun upsertPendingAssistant(text: String, isStreaming: Boolean) { + val currentId = pendingAssistantEntryId + if (currentId == null) { + pendingAssistantEntryId = + appendConversation( + role = VoiceConversationRole.Assistant, + text = text, + isStreaming = isStreaming, + ) + return + } + updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming) + } + + private fun onFinalTranscript(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + _liveTranscript.value = trimmed + if (lastFinalSegment == trimmed) return + lastFinalSegment = trimmed + sessionSegments.add(trimmed) + } + + private fun disableMic(status: String) { + stopRequested = true + restartJob?.cancel() + restartJob = null + _micEnabled.value = false + _isListening.value = false + _inputLevel.value = 0f + _statusText.value = status + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun hasMicPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun parseAssistantText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"] as? JsonArray ?: return null + + val parts = + content.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + if (obj["type"].asStringOrNull() != "text") return@mapNotNull null + obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + } + if (parts.isEmpty()) return null + return parts.joinToString("\n") + } + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _isListening.value = true + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) { + val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f) + _inputLevel.value = level + } + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + _inputLevel.value = 0f + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + _inputLevel.value = 0f + val status = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required" + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device" + SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device" + SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected" + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying" + else -> "Speech error ($error)" + } + _statusText.value = status + + if ( + error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS || + error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED || + error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE + ) { + disableMic(status) + return + } + + val restartDelayMs = + when (error) { + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, + -> 1_200L + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L + else -> 600L + } + scheduleRestart(delayMs = restartDelayMs) + } + + override fun onResults(results: Bundle?) { + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull() + if (!text.isNullOrBlank()) { + onFinalTranscript(text) + flushSessionToQueue() + sendQueuedIfIdle() + } + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull() + if (!text.isNullOrBlank()) { + _liveTranscript.value = text.trim() + } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} + +private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = + this as? JsonObject + +private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt index 04d18b622602..f00481982a33 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -54,6 +54,47 @@ class TalkModeManager( private const val tag = "TalkMode" private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" + private const val defaultTalkProvider = "elevenlabs" + + internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, + ) + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } + + internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + val providers = + rawProviders?.entries?.mapNotNull { (key, value) -> + val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null + val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null + providerId to providerConfig + }?.toMap().orEmpty() + val providerId = + normalizeTalkProviderId(rawProvider) + ?: providers.keys.sorted().firstOrNull() + ?: defaultTalkProvider + return TalkProviderConfigSelection( + provider = providerId, + config = providers[providerId] ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } } private val mainHandler = Handler(Looper.getMainLooper()) @@ -344,12 +385,12 @@ class TalkModeManager( val key = sessionKey.trim() if (key.isEmpty()) return if (chatSubscribedSessionKey == key) return - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + if (sent) { chatSubscribedSessionKey = key Log.d(tag, "chat.subscribe ok sessionKey=$key") - } catch (err: Throwable) { - Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") + } else { + Log.w(tag, "chat.subscribe failed sessionKey=$key") } } @@ -818,30 +859,49 @@ class TalkModeManager( val root = json.parseToJsonElement(res).asObjectOrNull() val config = root?.get("config").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull() + val selection = selectTalkProviderConfig(talk) + val activeProvider = selection?.provider ?: defaultTalkProvider + val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val aliases = - talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } }?.toMap().orEmpty() - val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val outputFormat = + activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() if (!isCanonicalMainSessionKey(mainSessionKey)) { mainSessionKey = mainKey } - defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + defaultVoiceId = + if (activeProvider == defaultTalkProvider) { + voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + } else { + voice + } voiceAliases = aliases if (!voiceOverrideActive) currentVoiceId = defaultVoiceId defaultModelId = model ?: defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } + apiKey = + if (activeProvider == defaultTalkProvider) { + key ?: envKey?.takeIf { it.isNotEmpty() } + } else { + null + } if (interrupt != null) interruptOnSpeech = interrupt + if (activeProvider != defaultTalkProvider) { + Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback") + } else if (selection?.normalizedPayload == true) { + Log.d(tag, "talk config provider=elevenlabs") + } } catch (_: Throwable) { defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback diff --git a/apps/android/app/src/main/res/font/manrope_400_regular.ttf b/apps/android/app/src/main/res/font/manrope_400_regular.ttf new file mode 100644 index 000000000000..9a108f1cee9d Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_400_regular.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_500_medium.ttf b/apps/android/app/src/main/res/font/manrope_500_medium.ttf new file mode 100644 index 000000000000..c6d28def6d56 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_500_medium.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_600_semibold.ttf b/apps/android/app/src/main/res/font/manrope_600_semibold.ttf new file mode 100644 index 000000000000..46a13d619899 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_600_semibold.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_700_bold.ttf b/apps/android/app/src/main/res/font/manrope_700_bold.ttf new file mode 100644 index 000000000000..62a618393905 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_700_bold.ttf differ diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt new file mode 100644 index 000000000000..95e145fb11fb --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/DeviceAuthPayloadTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceAuthPayloadTest { + @Test + fun buildV3_matchesCanonicalVector() { + val payload = + DeviceAuthPayload.buildV3( + deviceId = "dev-1", + clientId = "openclaw-macos", + clientMode = "ui", + role = "operator", + scopes = listOf("operator.admin", "operator.read"), + signedAtMs = 1_700_000_000_000, + token = "tok-123", + nonce = "nonce-abc", + platform = " IOS ", + deviceFamily = " iPhone ", + ) + + assertEquals( + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + payload, + ) + } + + @Test + fun normalizeMetadataField_asciiOnlyLowercase() { + assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS ")) + assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC ")) + assertEquals("", DeviceAuthPayload.normalizeMetadataField(null)) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt new file mode 100644 index 000000000000..e8a37aef21b5 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -0,0 +1,442 @@ +package ai.openclaw.android.gateway + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference + +private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { + private val tokens = mutableMapOf() + + override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() } + + override fun saveToken(deviceId: String, role: String, token: String) { + tokens["${deviceId.trim()}|${role.trim()}"] = token.trim() + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewaySessionInvokeTest { + @Test + fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val handshakeOrigin = AtomicReference(null) + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = deviceAuthStore, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + GatewaySession.InvokeResult.ok("""{"handled":true}""") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + val req = withTimeout(8_000) { invokeRequest.await() } + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-1", req.id) + assertEquals("node-1", req.nodeId) + assertEquals("debug.ping", req.command) + assertEquals("""{"ping":"pong"}""", req.paramsJson) + assertNull(handshakeOrigin.get()) + assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + true, + resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(), + ) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + server.shutdown() + } + } + + @Test + fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = deviceAuthStore, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + GatewaySession.InvokeResult.ok("""{"handled":true}""") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + + val req = withTimeout(8_000) { invokeRequest.await() } + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-2", req.id) + assertEquals("node-2", req.nodeId) + assertEquals("debug.raw", req.command) + assertEquals("""{"raw":true}""", req.paramsJson) + assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + server.shutdown() + } + } + + @Test + fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = deviceAuthStore, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + "CAMERA_PERMISSION_REQUIRED", + resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content, + ) + assertEquals( + "grant Camera permission", + resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content, + ) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + server.shutdown() + } + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt new file mode 100644 index 000000000000..ca8e8f21424a --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt @@ -0,0 +1,33 @@ +package ai.openclaw.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeErrorParserTest { + @Test + fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() { + val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code) + assertEquals("grant Camera permission", parsed.message) + assertTrue(parsed.hadExplicitCode) + assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage) + } + + @Test + fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() { + val parsed = parseInvokeErrorMessage("IllegalStateException: boom") + assertEquals("UNAVAILABLE", parsed.code) + assertEquals("IllegalStateException: boom", parsed.message) + assertFalse(parsed.hadExplicitCode) + } + + @Test + fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() { + val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback") + assertEquals("UNAVAILABLE", parsed.code) + assertEquals("fallback", parsed.message) + assertFalse(parsed.hadExplicitCode) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt new file mode 100644 index 000000000000..046f610bf5b8 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/DeviceHandlerTest.kt @@ -0,0 +1,82 @@ +package ai.openclaw.android.node + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DeviceHandlerTest { + @Test + fun handleDeviceInfo_returnsStablePayload() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceInfo(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content) + assertTrue(payload.getValue("deviceName").jsonPrimitive.content.isNotBlank()) + assertTrue(payload.getValue("modelIdentifier").jsonPrimitive.content.isNotBlank()) + assertTrue(payload.getValue("systemVersion").jsonPrimitive.content.isNotBlank()) + assertTrue(payload.getValue("appVersion").jsonPrimitive.content.isNotBlank()) + assertTrue(payload.getValue("appBuild").jsonPrimitive.content.isNotBlank()) + assertTrue(payload.getValue("locale").jsonPrimitive.content.isNotBlank()) + } + + @Test + fun handleDeviceStatus_returnsExpectedShape() { + val handler = DeviceHandler(appContext()) + + val result = handler.handleDeviceStatus(null) + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + val battery = payload.getValue("battery").jsonObject + val storage = payload.getValue("storage").jsonObject + val thermal = payload.getValue("thermal").jsonObject + val network = payload.getValue("network").jsonObject + + val state = battery.getValue("state").jsonPrimitive.content + assertTrue(state in setOf("unknown", "unplugged", "charging", "full")) + battery["level"]?.jsonPrimitive?.double?.let { level -> + assertTrue(level in 0.0..1.0) + } + battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean + + val totalBytes = storage.getValue("totalBytes").jsonPrimitive.content.toLong() + val freeBytes = storage.getValue("freeBytes").jsonPrimitive.content.toLong() + val usedBytes = storage.getValue("usedBytes").jsonPrimitive.content.toLong() + assertTrue(totalBytes >= 0L) + assertTrue(freeBytes >= 0L) + assertTrue(usedBytes >= 0L) + assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes) + + val thermalState = thermal.getValue("state").jsonPrimitive.content + assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical")) + + val networkStatus = network.getValue("status").jsonPrimitive.content + assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection")) + val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content } + assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") }) + + assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0) + } + + private fun appContext(): Context = RuntimeEnvironment.getApplication() + + private fun parsePayload(payloadJson: String?): JsonObject { + val jsonString = payloadJson ?: error("expected payload") + return Json.parseToJsonElement(jsonString).jsonObject + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt new file mode 100644 index 000000000000..148d3866346b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -0,0 +1,56 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawDeviceCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeCommandRegistryTest { + @Test + fun advertisedCommands_respectsFeatureAvailability() { + val commands = + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = false, + locationEnabled = false, + smsAvailable = false, + debugBuild = false, + ) + + assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue)) + assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue)) + assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) + assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(commands.contains("debug.logs")) + assertFalse(commands.contains("debug.ed25519")) + assertTrue(commands.contains("app.update")) + } + + @Test + fun advertisedCommands_includesFeatureCommandsWhenEnabled() { + val commands = + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = true, + locationEnabled = true, + smsAvailable = true, + debugBuild = true, + ) + + assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue)) + assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue)) + assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) + assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertTrue(commands.contains("debug.logs")) + assertTrue(commands.contains("debug.ed25519")) + assertTrue(commands.contains("app.update")) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt new file mode 100644 index 000000000000..7768e6e25dab --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt @@ -0,0 +1,146 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class NotificationsHandlerTest { + @Test + fun notificationsListReturnsStatusPayloadWhenDisabled() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertFalse(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(0, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, payload.getValue("notifications").jsonArray.size) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun notificationsListRequestsRebindWhenEnabledButDisconnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = false, + notifications = listOf(sampleEntry("n1")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(1, payload.getValue("notifications").jsonArray.size) + assertEquals(1, provider.rebindRequests) + } + + @Test + fun notificationsListDoesNotRequestRebindWhenConnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertTrue(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun sanitizeNotificationTextReturnsNullForBlankInput() { + assertNull(sanitizeNotificationText(null)) + assertNull(sanitizeNotificationText(" ")) + } + + @Test + fun sanitizeNotificationTextTrimsAndTruncates() { + val value = " ${"x".repeat(600)} " + val sanitized = sanitizeNotificationText(value) + + assertEquals(512, sanitized?.length) + assertTrue((sanitized ?: "").all { it == 'x' }) + } + + private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject { + val payloadJson = result.payloadJson ?: error("expected payload") + return Json.parseToJsonElement(payloadJson).jsonObject + } + + private fun appContext(): Context = RuntimeEnvironment.getApplication() + + private fun sampleEntry(key: String): DeviceNotificationEntry = + DeviceNotificationEntry( + key = key, + packageName = "com.example.app", + title = "Title", + text = "Text", + subText = null, + category = null, + channelId = null, + postTimeMs = 123L, + isOngoing = false, + isClearable = true, + ) +} + +private class FakeNotificationsStateProvider( + private val snapshot: DeviceNotificationSnapshot, +) : NotificationsStateProvider { + var rebindRequests: Int = 0 + private set + + override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot + + override fun requestServiceRebind(context: Context) { + rebindRequests += 1 + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt index 10ab733ae53f..41a9a7514e80 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt @@ -26,10 +26,24 @@ class OpenClawProtocolConstantsTest { assertEquals("camera", OpenClawCapability.Camera.rawValue) assertEquals("screen", OpenClawCapability.Screen.rawValue) assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue) + assertEquals("location", OpenClawCapability.Location.rawValue) + assertEquals("sms", OpenClawCapability.Sms.rawValue) + assertEquals("device", OpenClawCapability.Device.rawValue) } @Test fun screenCommandsUseStableStrings() { assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) } + + @Test + fun notificationsCommandsUseStableStrings() { + assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue) + } + + @Test + fun deviceCommandsUseStableStrings() { + assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue) + assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt new file mode 100644 index 000000000000..7dc2dd1a239b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.ui + +import java.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GatewayConfigResolverTest { + @Test + fun resolveScannedSetupCodeAcceptsRawSetupCode() { + val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""") + + val resolved = resolveScannedSetupCode(setupCode) + + assertEquals(setupCode, resolved) + } + + @Test + fun resolveScannedSetupCodeAcceptsQrJsonPayload() { + val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""") + val qrJson = + """ + { + "setupCode": "$setupCode", + "gatewayUrl": "wss://gateway.example:18789", + "auth": "password", + "urlSource": "gateway.remote.url" + } + """.trimIndent() + + val resolved = resolveScannedSetupCode(qrJson) + + assertEquals(setupCode, resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsInvalidInput() { + val resolved = resolveScannedSetupCode("not-a-valid-setup-code") + assertNull(resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() { + val qrJson = """{"setupCode":"invalid"}""" + val resolved = resolveScannedSetupCode(qrJson) + assertNull(resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() { + val qrJson = """{"setupCode":{"nested":"value"}}""" + val resolved = resolveScannedSetupCode(qrJson) + assertNull(resolved) + } + + private fun encodeSetupCode(payloadJson: String): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt new file mode 100644 index 000000000000..5daa62080d70 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkModeConfigParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun prefersNormalizedTalkProviderPayload() { + val talk = + json.parseToJsonElement( + """ + { + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == true) + assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + } + + @Test + fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + val talk = + json.parseToJsonElement( + """ + { + "voiceId": "voice-legacy", + "apiKey": "legacy-key" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == false) + assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content) + } +} diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts new file mode 100644 index 000000000000..99d1d8e4c60c --- /dev/null +++ b/apps/android/benchmark/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.test") +} + +android { + namespace = "ai.openclaw.android.benchmark" + compileSdk = 36 + + defaultConfig { + minSdk = 31 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR" + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +dependencies { + implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1") + implementation("androidx.test.ext:junit:1.2.1") + implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06") +} diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt new file mode 100644 index 000000000000..46181f6a9a18 --- /dev/null +++ b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt @@ -0,0 +1,76 @@ +package ai.openclaw.android.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StartupMacrobenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + private val packageName = "ai.openclaw.android" + + @Test + fun coldStartup() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(StartupTimingMetric()), + startupMode = StartupMode.COLD, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + pressHome() + startActivityAndWait() + } + } + } + + @Test + fun startupAndScrollFrameTiming() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(FrameTimingMetric()), + startupMode = StartupMode.WARM, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + startActivityAndWait() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val x = device.displayWidth / 2 + val yStart = (device.displayHeight * 0.8f).toInt() + val yEnd = (device.displayHeight * 0.25f).toInt() + repeat(4) { + device.swipe(x, yStart, x, yEnd, 24) + device.waitForIdle() + } + } + } + } + + private fun runBenchmarkOrSkip(run: () -> Unit) { + try { + run() + } catch (err: IllegalStateException) { + val message = err.message.orEmpty() + val knownDeviceIssue = + message.contains("Unable to confirm activity launch completion") || + message.contains("no renderthread slices", ignoreCase = true) + if (knownDeviceIssue) { + assumeTrue("Skipping benchmark on this device: $message", false) + } + throw err + } + } +} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index f79902d5615f..1d191c9e375c 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("com.android.application") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("com.android.application") version "9.0.1" apply false + id("com.android.test") version "9.0.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false } diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties index 5f84d966ee84..4134274afddb 100644 --- a/apps/android/gradle.properties +++ b/apps/android/gradle.properties @@ -3,3 +3,12 @@ org.gradle.warning.mode=none android.useAndroidX=true android.nonTransitiveRClass=true android.enableR8.fullMode=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.newDsl=true diff --git a/apps/android/gradle/gradle-daemon-jvm.properties b/apps/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000000..6c1139ec06ae --- /dev/null +++ b/apps/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/apps/android/scripts/perf-startup-benchmark.sh b/apps/android/scripts/perf-startup-benchmark.sh new file mode 100755 index 000000000000..70342d3cba48 --- /dev/null +++ b/apps/android/scripts/perf-startup-benchmark.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +RESULTS_DIR="$ANDROID_DIR/benchmark/results" +CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup" +BASELINE_JSON="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-benchmark.sh [--baseline ] + +Runs cold-start macrobenchmark only, then prints a compact summary. +Also saves a timestamped snapshot JSON under benchmark/results/. +If --baseline is omitted, compares against latest previous snapshot when available. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --baseline) + BASELINE_JSON="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +mkdir -p "$RESULTS_DIR" + +run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)" +trap 'rm -f "$run_log"' EXIT + +cd "$ANDROID_DIR" + +./gradlew :benchmark:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \ + --console=plain \ + >"$run_log" 2>&1 + +latest_json="$( + find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \ + -name '*benchmarkData.json' -type f \ + | while IFS= read -r file; do + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- +)" + +if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then + echo "benchmarkData.json not found after run." >&2 + tail -n 120 "$run_log" >&2 + exit 1 +fi + +timestamp="$(date +%Y%m%d-%H%M%S)" +snapshot_json="$RESULTS_DIR/startup-$timestamp.json" +cp "$latest_json" "$snapshot_json" + +median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")" +min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")" +max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")" +cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")" +device="$(jq -r '.context.build.model' "$snapshot_json")" +sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")" +runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")" + +printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \ + "$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk" +echo "snapshot_json=$snapshot_json" + +if [[ -z "$BASELINE_JSON" ]]; then + BASELINE_JSON="$( + find "$RESULTS_DIR" -name 'startup-*.json' -type f \ + | while IFS= read -r file; do + if [[ "$file" == "$snapshot_json" ]]; then + continue + fi + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- + )" +fi + +if [[ -n "$BASELINE_JSON" ]]; then + if [[ ! -f "$BASELINE_JSON" ]]; then + echo "Baseline file missing: $BASELINE_JSON" >&2 + exit 1 + fi + base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")" + delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')" + delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')" + echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%" +fi diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh new file mode 100755 index 000000000000..787d5fac3005 --- /dev/null +++ b/apps/android/scripts/perf-startup-hotspots.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PACKAGE="ai.openclaw.android" +ACTIVITY=".MainActivity" +DURATION_SECONDS="10" +OUTPUT_PERF_DATA="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-hotspots.sh [--package ] [--activity ] [--duration ] [--out ] + +Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries. +Default package/activity target OpenClaw Android startup. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --package) + PACKAGE="${2:-}" + shift 2 + ;; + --activity) + ACTIVITY="${2:-}" + shift 2 + ;; + --duration) + DURATION_SECONDS="${2:-}" + shift 2 + ;; + --out) + OUTPUT_PERF_DATA="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v uv >/dev/null 2>&1; then + echo "uv required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +if [[ -z "$OUTPUT_PERF_DATA" ]]; then + OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data" +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +simpleperf_dir="" +if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf" +elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf" +else + latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)" + if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then + simpleperf_dir="$latest_simpleperf" + fi +fi + +if [[ -z "$simpleperf_dir" ]]; then + echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2 + exit 1 +fi + +app_profiler="$simpleperf_dir/app_profiler.py" +report_py="$simpleperf_dir/report.py" +ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)" + +tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)" +trap 'rm -rf "$tmp_dir"' EXIT + +capture_log="$tmp_dir/capture.log" +dso_csv="$tmp_dir/dso.csv" +symbols_csv="$tmp_dir/symbols.csv" +children_txt="$tmp_dir/children.txt" + +cd "$ANDROID_DIR" +./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1 + +if ! uv run --no-project python3 "$app_profiler" \ + -p "$PACKAGE" \ + -a "$ACTIVITY" \ + -o "$OUTPUT_PERF_DATA" \ + --ndk_path "$ndk_path" \ + -r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \ + >"$capture_log" 2>&1; then + echo "simpleperf capture failed. tail(capture_log):" >&2 + tail -n 120 "$capture_log" >&2 + exit 1 +fi + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$dso_csv" 2>"$tmp_dir/report-dso.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso,symbol \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$symbols_csv" 2>"$tmp_dir/report-symbols.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --children \ + --sort dso,symbol \ + -n \ + --percent-limit 0.2 \ + --include-process-name "$PACKAGE" \ + >"$children_txt" 2>"$tmp_dir/report-children.err" + +clean_csv() { + awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1" +} + +echo "perf_data=$OUTPUT_PERF_DATA" +echo +echo "top_dso_self:" +clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}' +echo +echo "top_symbols_self:" +clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}' +echo +echo "app_path_clues_children:" +rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts index b3b43a445501..25e5d09bbe1d 100644 --- a/apps/android/settings.gradle.kts +++ b/apps/android/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "OpenClawNodeAndroid" include(":app") +include(":benchmark") diff --git a/apps/android/style.md b/apps/android/style.md new file mode 100644 index 000000000000..f2b892ac6ff8 --- /dev/null +++ b/apps/android/style.md @@ -0,0 +1,113 @@ +# OpenClaw Android UI Style Guide + +Scope: all native Android UI in `apps/android` (Jetpack Compose). +Goal: one coherent visual system across onboarding, settings, and future screens. + +## 1. Design Direction + +- Clean, quiet surfaces. +- Strong readability first. +- One clear primary action per screen state. +- Progressive disclosure for advanced controls. +- Deterministic flows: validate early, fail clearly. + +## 2. Style Baseline + +The onboarding flow defines the current visual baseline. +New screens should match that language unless there is a strong product reason not to. + +Baseline traits: + +- Light neutral background with subtle depth. +- Clear blue accent for active/primary states. +- Strong border hierarchy for structure. +- Medium/semibold typography (no thin text). +- Divider-and-spacing layout over heavy card nesting. + +## 3. Core Tokens + +Use these as shared design tokens for new Compose UI. + +- Background gradient: `#FFFFFF`, `#F7F8FA`, `#EFF1F5` +- Surface: `#F6F7FA` +- Border: `#E5E7EC` +- Border strong: `#D6DAE2` +- Text primary: `#17181C` +- Text secondary: `#4D5563` +- Text tertiary: `#8A92A2` +- Accent primary: `#1D5DD8` +- Accent soft: `#ECF3FF` +- Success: `#2F8C5A` +- Warning: `#C8841A` + +Rule: do not introduce random per-screen colors when an existing token fits. + +## 4. Typography + +Primary type family: Manrope (`400/500/600/700`). + +Recommended scale: + +- Display: `34sp / 40sp`, bold +- Section title: `24sp / 30sp`, semibold +- Headline/action: `16sp / 22sp`, semibold +- Body: `15sp / 22sp`, medium +- Callout/helper: `14sp / 20sp`, medium +- Caption 1: `12sp / 16sp`, medium +- Caption 2: `11sp / 14sp`, medium + +Use monospace only for commands, setup codes, endpoint-like values. +Hard rule: avoid ultra-thin weights on light backgrounds. + +## 5. Layout And Spacing + +- Respect safe drawing insets. +- Keep content hierarchy mostly via spacing + dividers. +- Prefer vertical rhythm from `8/10/12/14/20dp`. +- Use pinned bottom actions for multi-step or high-importance flows. +- Avoid unnecessary container nesting. + +## 6. Buttons And Actions + +- Primary action: filled accent button, visually dominant. +- Secondary action: lower emphasis (outlined/text/surface button). +- Icon-only buttons must remain legible and >=44dp target. +- Back buttons in action rows use rounded-square shape, not circular by default. + +## 7. Inputs And Forms + +- Always show explicit label or clear context title. +- Keep helper copy short and actionable. +- Validate before advancing steps. +- Prefer immediate inline errors over hidden failure states. +- Keep optional advanced fields explicit (`Manual`, `Advanced`, etc.). + +## 8. Progress And Multi-Step Flows + +- Use clear step count (`Step X of N`). +- Use labeled progress rail/indicator when steps are discrete. +- Keep navigation predictable: back/next behavior should never surprise. + +## 9. Accessibility + +- Minimum practical touch target: `44dp`. +- Do not rely on color alone for status. +- Preserve high contrast for all text tiers. +- Add meaningful `contentDescription` for icon-only controls. + +## 10. Architecture Rules + +- Durable UI state in `MainViewModel`. +- Composables: state in, callbacks out. +- No business/network logic in composables. +- Keep side effects explicit (`LaunchedEffect`, activity result APIs). + +## 11. Source Of Truth + +- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt` +- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` +- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` + +If style and implementation diverge, update both in the same change. diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0656afbf2d7e..6fcad4635b0c 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleVersion - 20260220 + 20260226 NSExtension NSExtensionAttributes diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 000000000000..eeed54c46526 --- /dev/null +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +import Darwin + +/// Shared device and platform info for Settings, gateway node payloads, and device status. +enum DeviceInfoHelper { + /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + let name = switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPadOS" + case .phone: + "iOS" + default: + "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. + static func platformStringForDisplay() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Device family for display: "iPad", "iPhone", or "iOS". + static func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPad" + case .phone: + "iPhone" + default: + "iOS" + } + } + + /// Machine model identifier from uname (e.g. "iPhone17,1"). + static func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "unknown" : trimmed + } + + /// App marketing version only, e.g. "2026.2.0" or "dev". + static func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + /// App build string, e.g. "123" or "". + static func appBuild() -> String { + let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. + static func openClawVersionString() -> String { + let version = appVersion() + let build = appBuild() + if build.isEmpty || build == version { + return version + } + return "\(version) (\(build))" + } +} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift index fed2716b5b8a..a80a98101fae 100644 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ b/apps/ios/Sources/Device/DeviceStatusService.swift @@ -26,12 +26,12 @@ final class DeviceStatusService: DeviceStatusServicing { func info() -> OpenClawDeviceInfoPayload { let device = UIDevice.current - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + let appVersion = DeviceInfoHelper.appVersion() + let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild()) let locale = Locale.preferredLanguages.first ?? Locale.current.identifier return OpenClawDeviceInfoPayload( deviceName: device.name, - modelIdentifier: Self.modelIdentifier(), + modelIdentifier: DeviceInfoHelper.modelIdentifier(), systemName: device.systemName, systemVersion: device.systemVersion, appVersion: appVersion, @@ -75,13 +75,8 @@ final class DeviceStatusService: DeviceStatusServicing { return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) } - private static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed + /// Fallback for payloads that require a non-empty build (e.g. "0"). + private static func fallbackAppBuild(_ build: String) -> String { + build.isEmpty ? "0" : build } } diff --git a/apps/ios/Sources/Device/NetworkStatusService.swift b/apps/ios/Sources/Device/NetworkStatusService.swift index 7d92d1cc1ca1..bc27eb19791f 100644 --- a/apps/ios/Sources/Device/NetworkStatusService.swift +++ b/apps/ios/Sources/Device/NetworkStatusService.swift @@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable { func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload { await withCheckedContinuation { cont in let monitor = NWPathMonitor() - let queue = DispatchQueue(label: "bot.molt.ios.network-status") + let queue = DispatchQueue(label: "ai.openclaw.ios.network-status") let state = NetworkStatusState() monitor.pathUpdateHandler = { path in diff --git a/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 000000000000..0624e976b515 --- /dev/null +++ b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 2b7f94ba4532..a770fcb2c6f8 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -921,44 +921,6 @@ final class GatewayConnectionController { private static func motionAvailable() -> Bool { CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() } - - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - let name = switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } } #if DEBUG @@ -980,19 +942,19 @@ extension GatewayConnectionController { } func _test_platformString() -> String { - self.platformString() + DeviceInfoHelper.platformString() } func _test_deviceFamily() -> String { - self.deviceFamily() + DeviceInfoHelper.deviceFamily() } func _test_modelIdentifier() -> String { - self.modelIdentifier() + DeviceInfoHelper.modelIdentifier() } func _test_appVersion() -> String { - self.appVersion() + DeviceInfoHelper.appVersion() } func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index ce1ba4bf2cb6..04bb220d5f36 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -104,7 +104,7 @@ final class GatewayDiscoveryModel { } self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)")) + browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)")) } } diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 3ff57ad2e674..49db9bb1bfc6 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -25,7 +25,7 @@ enum GatewaySettingsStore { private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" - private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey" + private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." static func bootstrapPersistence() { self.ensureStableInstanceID() @@ -145,25 +145,26 @@ enum GatewaySettingsStore { case discovered } - static func loadTalkElevenLabsApiKey() -> String? { + static func loadTalkProviderApiKey(provider: String) -> String? { + guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let value = KeychainStore.loadString( service: self.talkService, - account: self.talkElevenLabsApiKeyAccount)? + account: account)? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { return value } return nil } - static func saveTalkElevenLabsApiKey(_ apiKey: String?) { + static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) { + guard let providerId = self.normalizedTalkProviderID(provider) else { return } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.delete(service: self.talkService, account: account) return } - _ = KeychainStore.saveString( - trimmed, - service: self.talkService, - account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) } static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { @@ -278,6 +279,15 @@ enum GatewaySettingsStore { "gateway-password.\(instanceId)" } + private static func talkProviderApiKeyAccount(providerId: String) -> String { + self.talkProviderApiKeyAccountPrefix + providerId + } + + private static func normalizedTalkProviderID(_ provider: String) -> String? { + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + private static func ensureStableInstanceID() { let defaults = UserDefaults.standard diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c3b469e70928..12d340594f30 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260220 + 20260226 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift new file mode 100644 index 000000000000..08ef81e0cced --- /dev/null +++ b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift @@ -0,0 +1,103 @@ +import Foundation +import OpenClawKit + +extension NodeAppModel { + static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { + var normalized = params + normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.promptId = self.trimmedOrNil(params.promptId) + normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.kind = self.trimmedOrNil(params.kind) + normalized.details = self.trimmedOrNil(params.details) + normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) + normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) + + let normalizedActions = self.normalizeWatchActions( + params.actions, + kind: normalized.kind, + promptId: normalized.promptId) + normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions + return normalized + } + + static func normalizeWatchActions( + _ actions: [OpenClawWatchAction]?, + kind: String?, + promptId: String?) -> [OpenClawWatchAction] + { + let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction( + id: id, + label: label, + style: self.trimmedOrNil(action.style)) + } + if !provided.isEmpty { + return Array(provided.prefix(4)) + } + + // Only auto-insert quick actions when this is a prompt/decision flow. + guard promptId?.isEmpty == false else { + return [] + } + + let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if normalizedKind.contains("approval") || normalizedKind.contains("approve") { + return [ + OpenClawWatchAction(id: "approve", label: "Approve"), + OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + return [ + OpenClawWatchAction(id: "done", label: "Done"), + OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + static func normalizedWatchRisk( + _ risk: OpenClawWatchRisk?, + priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? + { + if let risk { return risk } + switch priority { + case .passive: + return .low + case .active: + return .medium + case .timeSensitive: + return .high + case nil: + return nil + } + } + + static func normalizedWatchPriority( + _ priority: OpenClawNotificationPriority?, + risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? + { + if let priority { return priority } + switch risk { + case .low: + return .passive + case .medium: + return .active + case .high: + return .timeSensitive + case nil: + return nil + } + } + + static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5bd98e6f4923..d763a3b908f9 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -3,6 +3,7 @@ import OpenClawKit import OpenClawProtocol import Observation import os +import Security import SwiftUI import UIKit import UserNotifications @@ -37,9 +38,22 @@ private final class NotificationInvokeLatch: @unchecked Sendable { cont?.resume(returning: response) } } + +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + @MainActor @Observable final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") @@ -74,6 +88,8 @@ final class NodeAppModel { var gatewayAgents: [AgentSummary] = [] var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -485,21 +501,14 @@ final class NodeAppModel { } } - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static var apnsEnvironment: String { #if DEBUG "sandbox" @@ -508,17 +517,6 @@ final class NodeAppModel { #endif } - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - private func refreshBrandingFromGateway() async { do { let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) @@ -699,117 +697,6 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - self.deepLinkLogger.info( - "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" - ) - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - self.recordShareEvent("Rejected: message too large (\(message.count) chars).") - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - self.recordShareEvent("Failed: gateway not connected.") - self.deepLinkLogger.error("agent deep link rejected: gateway not connected") - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - self.recordShareEvent("Sent to gateway (\(message.count) chars).") - self.deepLinkLogger.info("agent deep link forwarded to gateway") - self.openChatRequestID &+= 1 - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - self.recordShareEvent("Failed: \(error.localizedDescription)") - self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command @@ -1603,8 +1490,9 @@ private extension NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawWatchCommand.notify.rawValue: let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedParams = Self.normalizeWatchNotifyParams(params) + let title = normalizedParams.title + let body = normalizedParams.body if title.isEmpty && body.isEmpty { return BridgeInvokeResponse( id: req.id, @@ -1616,13 +1504,13 @@ private extension NodeAppModel { do { let result = try await self.watchMessagingService.sendNotification( id: req.id, - params: params) + params: normalizedParams) if result.queuedForDelivery || !result.deliveredImmediately { let invokeID = req.id Task { @MainActor in await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( invokeID: invokeID, - params: params, + params: normalizedParams, sendResult: result) } } @@ -2560,6 +2448,229 @@ extension NodeAppModel { } } +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + extension NodeAppModel { func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { await self.handleWatchQuickReply(event) @@ -2607,5 +2718,13 @@ extension NodeAppModel { func _test_queuedWatchReplyCount() -> Int { self.queuedWatchReplies.count } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } } #endif diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 335e09fd986c..0dc0c4cac26f 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -182,8 +182,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc actionLabel: actionLabel, sessionKey: sessionKey) default: + break + } + + guard response.actionIdentifier.hasPrefix(WatchPromptNotificationBridge.actionIdentifierPrefix) else { + return nil + } + let indexString = String( + response.actionIdentifier.dropFirst(WatchPromptNotificationBridge.actionIdentifierPrefix.count)) + guard let actionIndex = Int(indexString), actionIndex >= 0 else { return nil } + let actionIdKey = WatchPromptNotificationBridge.actionIDKey(index: actionIndex) + let actionLabelKey = WatchPromptNotificationBridge.actionLabelKey(index: actionIndex) + let actionId = (userInfo[actionIdKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { + return nil + } + let actionLabel = userInfo[actionLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) } private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { @@ -243,6 +265,9 @@ enum WatchPromptNotificationBridge { static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" static let actionPrimaryIdentifier = "openclaw.watch.action.primary" static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let actionIdentifierPrefix = "openclaw.watch.action." + static let actionIDKeyPrefix = "openclaw.watch.action.id." + static let actionLabelKeyPrefix = "openclaw.watch.action.label." static let categoryPrefix = "openclaw.watch.prompt.category." @MainActor @@ -264,16 +289,15 @@ enum WatchPromptNotificationBridge { guard !id.isEmpty, !label.isEmpty else { return nil } return OpenClawWatchAction(id: id, label: label, style: action.style) } - let primaryAction = normalizedActions.first - let secondaryAction = normalizedActions.dropFirst().first + let displayedActions = Array(normalizedActions.prefix(4)) let center = UNUserNotificationCenter.current() var categoryIdentifier = "" - if let primaryAction { + if !displayedActions.isEmpty { let categoryID = "\(self.categoryPrefix)\(invokeID)" let category = UNNotificationCategory( identifier: categoryID, - actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), + actions: self.categoryActions(displayedActions), intentIdentifiers: [], options: []) await self.upsertNotificationCategory(category, center: center) @@ -289,13 +313,16 @@ enum WatchPromptNotificationBridge { if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { userInfo[self.sessionKeyKey] = sessionKey } - if let primaryAction { - userInfo[self.actionPrimaryIDKey] = primaryAction.id - userInfo[self.actionPrimaryLabelKey] = primaryAction.label - } - if let secondaryAction { - userInfo[self.actionSecondaryIDKey] = secondaryAction.id - userInfo[self.actionSecondaryLabelKey] = secondaryAction.label + for (index, action) in displayedActions.enumerated() { + userInfo[self.actionIDKey(index: index)] = action.id + userInfo[self.actionLabelKey(index: index)] = action.label + if index == 0 { + userInfo[self.actionPrimaryIDKey] = action.id + userInfo[self.actionPrimaryLabelKey] = action.label + } else if index == 1 { + userInfo[self.actionSecondaryIDKey] = action.id + userInfo[self.actionSecondaryLabelKey] = action.label + } } let content = UNMutableNotificationContent() @@ -324,24 +351,30 @@ enum WatchPromptNotificationBridge { try? await self.addNotificationRequest(request, center: center) } - private static func categoryActions( - primaryAction: OpenClawWatchAction, - secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] - { - var actions: [UNNotificationAction] = [ - UNNotificationAction( - identifier: self.actionPrimaryIdentifier, - title: primaryAction.label, - options: self.notificationActionOptions(style: primaryAction.style)) - ] - if let secondaryAction { - actions.append( - UNNotificationAction( - identifier: self.actionSecondaryIdentifier, - title: secondaryAction.label, - options: self.notificationActionOptions(style: secondaryAction.style))) - } - return actions + static func actionIDKey(index: Int) -> String { + "\(self.actionIDKeyPrefix)\(index)" + } + + static func actionLabelKey(index: Int) -> String { + "\(self.actionLabelKeyPrefix)\(index)" + } + + private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { + actions.enumerated().map { index, action in + let identifier: String + switch index { + case 0: + identifier = self.actionPrimaryIdentifier + case 1: + identifier = self.actionSecondaryIdentifier + default: + identifier = "\(self.actionIdentifierPrefix)\(index)" + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: self.notificationActionOptions(style: action.style)) + } } private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index da893d3c9439..dd0f389ed4d4 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -88,6 +88,7 @@ struct RootCanvas: View { } } .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index 11052f235432..c353d86f22d7 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable { outPath: outPath) let state = CaptureState() - let recordQueue = DispatchQueue(label: "bot.molt.screenrecord") + let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord") try await self.startCapture(state: state, config: config, recordQueue: recordQueue) try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 024a4cbf42b1..d9e1efd772d2 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -22,7 +22,6 @@ struct SettingsTab: View { @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false - @AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @@ -326,10 +325,6 @@ struct SettingsTab: View { .font(.footnote) .foregroundStyle(.secondary) } - self.featureToggle( - "Voice Directive Hint", - isOn: self.$talkVoiceDirectiveHintEnabled, - help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.") self.featureToggle( "Show Talk Button", isOn: self.$talkButtonEnabled, @@ -374,9 +369,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - LabeledContent("Device", value: self.deviceFamily()) - LabeledContent("Platform", value: self.platformString()) - LabeledContent("OpenClaw", value: self.openClawVersionString()) + LabeledContent("Device", value: DeviceInfoHelper.deviceFamily()) + LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay()) + LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString()) } } } @@ -584,32 +579,6 @@ struct SettingsTab: View { return trimmed.isEmpty ? "Not connected" : trimmed } - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func openClawVersionString() -> String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedBuild.isEmpty || trimmedBuild == version { - return version - } - return "\(version) (\(trimmedBuild))" - } - private func featureToggle( _ title: String, isOn: Binding, diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 8f208c66d505..239cb1868ad9 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -16,6 +16,7 @@ import Speech final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false @@ -94,7 +95,7 @@ final class TalkModeManager: NSObject { private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? private var incrementalSpeechPrefetchMonitorTask: Task? - private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") + private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode") init(allowSimulatorCapture: Bool = false) { self.allowSimulatorCapture = allowSimulatorCapture @@ -849,11 +850,10 @@ final class TalkModeManager: NSObject { private func buildPrompt(transcript: String) -> String { let interrupted = self.lastInterruptedAtSeconds self.lastInterruptedAtSeconds = nil - let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true return TalkPromptBuilder.build( transcript: transcript, interruptedAtSeconds: interrupted, - includeVoiceDirectiveHint: includeVoiceDirectiveHint) + includeVoiceDirectiveHint: false) } private enum ChatCompletionState: CustomStringConvertible { @@ -1885,6 +1885,38 @@ extension TalkModeManager { return trimmed } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: Any] + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? { + guard let talk else { return nil } + let rawProvider = talk["provider"] as? String + let rawProviders = talk["providers"] as? [String: Any] + guard rawProvider != nil || rawProviders != nil else { return nil } + let providers = rawProviders ?? [:] + let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let config = entry.value as? [String: Any] + else { return } + acc[providerID] = config + } + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:]) + } + func reloadConfig() async { guard let gateway else { return } do { @@ -1892,8 +1924,16 @@ extension TalkModeManager { guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] - self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = talk?["voiceAliases"] as? [String: Any] { + let selection = Self.selectTalkProviderConfig(talk) + if talk != nil, selection == nil { + GatewayDiagnostics.log( + "talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers") + } + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config + self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { var resolved: [String: String] = [:] for (key, value) in aliases { guard let id = value as? String else { continue } @@ -1909,22 +1949,28 @@ extension TalkModeManager { if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId } - let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } - self.defaultOutputFormat = (talk?["outputFormat"] as? String)? + self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) - let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey()) + let localApiKey = Self.normalizedTalkApiKey( + GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) if rawConfigApiKey == Self.redactedConfigSentinel { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") } else { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey } + if activeProvider != Self.defaultTalkProvider { + self.apiKey = nil + GatewayDiagnostics.log( + "talk provider '\(activeProvider)' not yet supported on iOS; using system voice fallback") + } self.gatewayTalkDefaultVoiceId = self.defaultVoiceId self.gatewayTalkDefaultModelId = self.defaultModelId self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) @@ -1932,6 +1978,9 @@ extension TalkModeManager { if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } + if selection != nil { + GatewayDiagnostics.log("talk config provider=\(activeProvider)") + } } catch { self.defaultModelId = Self.defaultModelIdFallback if !self.modelOverrideActive { diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 5b1ba7d70e63..514ca7326736 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -4,6 +4,9 @@ Sources/Gateway/GatewayDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift Sources/Gateway/KeychainStore.swift Sources/Camera/CameraController.swift +Sources/Device/DeviceInfoHelper.swift +Sources/Device/DeviceStatusService.swift +Sources/Device/NetworkStatusService.swift Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/OpenClawApp.swift diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index b82ae7161687..3c1b25bce077 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -1,5 +1,6 @@ import Foundation import Network +import OpenClawKit import Testing @testable import OpenClaw diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 7e67ab84a972..0bac40152361 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -9,9 +9,11 @@ private struct KeychainEntry: Hashable { private let gatewayService = "ai.openclaw.gateway" private let nodeService = "ai.openclaw.node" +private let talkService = "ai.openclaw.talk" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") +private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -196,4 +198,17 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { let loaded = GatewaySettingsStore.loadLastGatewayConnection() #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) } + + @Test func talkProviderApiKey_genericRoundTrip() { + let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry]) + defer { restoreKeychain(keychainSnapshot) } + + _ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account) + + GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key") + + GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) + } } diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 7fc8d8270443..b1a0354205c6 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleVersion - 20260220 + 20260226 diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift index 827be250ed7f..e56f4aa35b5a 100644 --- a/apps/ios/Tests/KeychainStoreTests.swift +++ b/apps/ios/Tests/KeychainStoreTests.swift @@ -4,7 +4,7 @@ import Testing @Suite struct KeychainStoreTests { @Test func saveLoadUpdateDeleteRoundTrip() { - let service = "bot.molt.tests.\(UUID().uuidString)" + let service = "ai.openclaw.tests.\(UUID().uuidString)" let account = "value" #expect(KeychainStore.delete(service: service, account: account)) diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 3d015afae84b..dbeee118a4a4 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,8 +29,35 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + @MainActor -private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { var currentStatus = WatchMessagingStatus( supported: true, paired: true, @@ -275,6 +302,79 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(watchService.lastSent == nil) } + @Test @MainActor func handleInvokeWatchNotifyAddsDefaultActionsForPrompt() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Task", + body: "Action needed", + priority: .passive, + promptId: "prompt-123") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-default-actions", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.risk == .low) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) + } + + @Test @MainActor func handleInvokeWatchNotifyAddsApprovalDefaults() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Approval", + body: "Allow command?", + promptId: "prompt-approval", + kind: "approval") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-approval-defaults", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["approve", "decline", "open_phone", "escalate"]) + #expect(watchService.lastSent?.params.actions?[1].style == "destructive") + } + + @Test @MainActor func handleInvokeWatchNotifyDerivesPriorityFromRiskAndCapsActions() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Urgent", + body: "Check now", + risk: .high, + actions: [ + OpenClawWatchAction(id: "a1", label: "A1"), + OpenClawWatchAction(id: "a2", label: "A2"), + OpenClawWatchAction(id: "a3", label: "A3"), + OpenClawWatchAction(id: "a4", label: "A4"), + OpenClawWatchAction(id: "a5", label: "A5"), + ]) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-derive-priority", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.priority == .timeSensitive) + #expect(watchService.lastSent?.params.risk == .high) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["a1", "a2", "a3", "a4"]) + } + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { let watchService = MockWatchMessagingService() watchService.sendError = NSError( @@ -327,6 +427,58 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..fd6b535f8a3b --- /dev/null +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import OpenClaw + +@MainActor +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + "voiceId": "voice-legacy", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.config["voiceId"] as? String == "voice-normalized") + } + + @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: Any] = [ + "voiceId": "voice-legacy", + "apiKey": "legacy-key", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection == nil) + } +} diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index cc5dbf6cdda1..3551b0af6f48 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleVersion - 20260220 + 20260226 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 2d6b7baa7b87..70451f55eb59 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleVersion - 20260220 + 20260226 NSExtension NSExtensionAttributes diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index adaa3fc29fb6..8dbb75a8c262 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("bot.molt.ios") +app_identifier("ai.openclaw.ios") # Auth is expected via App Store Connect API key. # Provide either: diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 613322f3e8ed..b433ca8a2bba 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,8 +92,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.26" + CFBundleVersion: "20260226" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -146,8 +146,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.26" + CFBundleVersion: "20260226" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -176,8 +176,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.26" + CFBundleVersion: "20260226" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -200,8 +200,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.26" + CFBundleVersion: "20260226" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -210,6 +210,9 @@ targets: OpenClawTests: type: bundle.unit-test platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig sources: - path: Tests dependencies: @@ -219,6 +222,9 @@ targets: - sdk: AppIntents.framework settings: base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete @@ -228,5 +234,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.26" + CFBundleVersion: "20260226" diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b1..89bbefc5b025 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift index 57164ebb892d..6340dee2ca52 100644 --- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift +++ b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift @@ -17,9 +17,14 @@ enum AgentWorkspace { AgentWorkspace.userFilename, AgentWorkspace.bootstrapFilename, ] - enum BootstrapSafety: Equatable { - case safe - case unsafe (reason: String) + struct BootstrapSafety: Equatable { + let unsafeReason: String? + + static let safe = Self(unsafeReason: nil) + + static func blocked(_ reason: String) -> Self { + Self(unsafeReason: reason) + } } static func displayPath(for url: URL) -> String { @@ -71,9 +76,7 @@ enum AgentWorkspace { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return .safe } - if !isDir.boolValue { - return .unsafe (reason: "Workspace path points to a file.") - } + if !isDir.boolValue { return .blocked("Workspace path points to a file.") } let agentsURL = self.agentsURL(workspaceURL: workspaceURL) if fm.fileExists(atPath: agentsURL.path) { return .safe @@ -82,9 +85,9 @@ enum AgentWorkspace { let entries = try self.workspaceEntries(workspaceURL: workspaceURL) return entries.isEmpty ? .safe - : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.") } catch { - return .unsafe (reason: "Couldn't inspect the workspace folder.") + return .blocked("Couldn't inspect the workspace folder.") } } diff --git a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift b/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift deleted file mode 100644 index 06f107d6c6e3..000000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift +++ /dev/null @@ -1,234 +0,0 @@ -import AppKit -import Combine -import SwiftUI - -@MainActor -struct AnthropicAuthControls: View { - let connectionMode: AppState.ConnectionMode - - @State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus() - @State private var pkce: AnthropicOAuth.PKCE? - @State private var code: String = "" - @State private var busy = false - @State private var statusText: String? - @State private var autoDetectClipboard = true - @State private var autoConnectClipboard = true - @State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount - - private static let clipboardPoll: AnyPublisher = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - if self.connectionMode != .local { - Text("Gateway isn’t running locally; OAuth must be created on the gateway host.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 10) { - Circle() - .fill(self.oauthStatus.isConnected ? Color.green : Color.orange) - .frame(width: 8, height: 8) - Text(self.oauthStatus.shortDescription) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer() - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path)) - - Button("Refresh") { - self.refresh() - } - .buttonStyle(.bordered) - } - - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - - HStack(spacing: 12) { - Button { - self.startOAuth() - } label: { - if self.busy { - ProgressView().controlSize(.small) - } else { - Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.connectionMode != .local || self.busy) - - if self.pkce != nil { - Button("Cancel") { - self.pkce = nil - self.code = "" - self.statusText = nil - } - .buttonStyle(.bordered) - .disabled(self.busy) - } - } - - if self.pkce != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste `code#state`") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - - TextField("code#state", text: self.$code) - .textFieldStyle(.roundedBorder) - .disabled(self.busy) - - Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Button("Connect") { - Task { await self.finishOAuth() } - } - .buttonStyle(.bordered) - .disabled(self.busy || self.connectionMode != .local || self.code - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty) - } - } - - if let statusText, !statusText.isEmpty { - Text(statusText) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .onAppear { - self.refresh() - } - .onReceive(Self.clipboardPoll) { _ in - self.pollClipboardIfNeeded() - } - } - - private func refresh() { - let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus() - if imported != nil { - self.statusText = "Imported existing OAuth credentials." - } - } - - private func startOAuth() { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - self.busy = true - defer { self.busy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.pkce = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.statusText = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.statusText = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - private func finishOAuth() async { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - guard let pkce = self.pkce else { return } - self.busy = true - defer { self.busy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else { - self.statusText = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refresh() - self.pkce = nil - self.code = "" - self.statusText = "Connected. OpenClaw can now use Claude via OAuth." - } catch { - self.statusText = "OAuth failed: \(error.localizedDescription)" - } - } - - private func pollClipboardIfNeeded() { - guard self.connectionMode == .local else { return } - guard self.pkce != nil else { return } - guard !self.busy else { return } - guard self.autoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.lastPasteboardChangeCount else { return } - self.lastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.pkce, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.code != next { - self.code = next - self.statusText = "Detected `code#state` from clipboard." - } - - guard self.autoConnectClipboard else { return } - Task { await self.finishOAuth() } - } -} - -#if DEBUG -extension AnthropicAuthControls { - init( - connectionMode: AppState.ConnectionMode, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus, - pkce: AnthropicOAuth.PKCE? = nil, - code: String = "", - busy: Bool = false, - statusText: String? = nil, - autoDetectClipboard: Bool = true, - autoConnectClipboard: Bool = true) - { - self.connectionMode = connectionMode - self._oauthStatus = State(initialValue: oauthStatus) - self._pkce = State(initialValue: pkce) - self._code = State(initialValue: code) - self._busy = State(initialValue: busy) - self._statusText = State(initialValue: statusText) - self._autoDetectClipboard = State(initialValue: autoDetectClipboard) - self._autoConnectClipboard = State(initialValue: autoConnectClipboard) - self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift deleted file mode 100644 index f594cc04c311..000000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift +++ /dev/null @@ -1,383 +0,0 @@ -import CryptoKit -import Foundation -import OSLog -import Security - -struct AnthropicOAuthCredentials: Codable { - let type: String - let refresh: String - let access: String - let expires: Int64 -} - -enum AnthropicAuthMode: Equatable { - case oauthFile - case oauthEnv - case apiKeyEnv - case missing - - var shortLabel: String { - switch self { - case .oauthFile: "OAuth (OpenClaw token file)" - case .oauthEnv: "OAuth (env var)" - case .apiKeyEnv: "API key (env var)" - case .missing: "Missing credentials" - } - } - - var isConfigured: Bool { - switch self { - case .missing: false - case .oauthFile, .oauthEnv, .apiKeyEnv: true - } - } -} - -enum AnthropicAuthResolver { - static func resolve( - environment: [String: String] = ProcessInfo.processInfo.environment, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore - .anthropicOAuthStatus()) -> AnthropicAuthMode - { - if oauthStatus.isConnected { return .oauthFile } - - if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return .oauthEnv - } - - if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !key.isEmpty - { - return .apiKeyEnv - } - - return .missing - } -} - -enum AnthropicOAuth { - private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth") - - private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! - private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! - private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" - private static let scopes = "org:create_api_key user:profile user:inference" - - struct PKCE { - let verifier: String - let challenge: String - } - - static func generatePKCE() throws -> PKCE { - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - let verifier = Data(bytes).base64URLEncodedString() - let hash = SHA256.hash(data: Data(verifier.utf8)) - let challenge = Data(hash).base64URLEncodedString() - return PKCE(verifier: verifier, challenge: challenge) - } - - static func buildAuthorizeURL(pkce: PKCE) -> URL { - var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! - components.queryItems = [ - URLQueryItem(name: "code", value: "true"), - URLQueryItem(name: "client_id", value: self.clientId), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: self.redirectURI), - URLQueryItem(name: "scope", value: self.scopes), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - // Match legacy flow: state is the verifier. - URLQueryItem(name: "state", value: pkce.verifier), - ] - return components.url! - } - - static func exchangeCode( - code: String, - state: String, - verifier: String) async throws -> AnthropicOAuthCredentials - { - let payload: [String: Any] = [ - "grant_type": "authorization_code", - "client_id": self.clientId, - "code": code, - "state": state, - "redirect_uri": self.redirectURI, - "code_verifier": verifier, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = decoded?["refresh_token"] as? String - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let refresh, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - // Match legacy flow: expiresAt = now + expires_in - 5 minutes. - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } - - static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { - let payload: [String: Any] = [ - "grant_type": "refresh_token", - "client_id": self.clientId, - "refresh_token": refreshToken, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } -} - -enum OpenClawOAuthStore { - static let oauthFilename = "oauth.json" - private static let providerKey = "anthropic" - private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR" - private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" - - enum AnthropicOAuthStatus: Equatable { - case missingFile - case unreadableFile - case invalidJSON - case missingProviderEntry - case missingTokens - case connected(expiresAtMs: Int64?) - - var isConnected: Bool { - if case .connected = self { return true } - return false - } - - var shortDescription: String { - switch self { - case .missingFile: "OpenClaw OAuth token file not found" - case .unreadableFile: "OpenClaw OAuth token file not readable" - case .invalidJSON: "OpenClaw OAuth token file invalid" - case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file" - case .missingTokens: "Anthropic entry missing tokens" - case .connected: "OpenClaw OAuth credentials found" - } - } - } - - static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - let home = FileManager().homeDirectoryForCurrentUser - return home.appendingPathComponent(".openclaw", isDirectory: true) - .appendingPathComponent("credentials", isDirectory: true) - } - - static func oauthURL() -> URL { - self.oauthDir().appendingPathComponent(self.oauthFilename) - } - - static func legacyOAuthURLs() -> [URL] { - var urls: [URL] = [] - let env = ProcessInfo.processInfo.environment - if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) - } - - let home = FileManager().homeDirectoryForCurrentUser - urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) - - var seen = Set() - return urls.filter { url in - let path = url.standardizedFileURL.path - if seen.contains(path) { return false } - seen.insert(path) - return true - } - } - - static func importLegacyAnthropicOAuthIfNeeded() -> URL? { - let dest = self.oauthURL() - guard !FileManager().fileExists(atPath: dest.path) else { return nil } - - for url in self.legacyOAuthURLs() { - guard FileManager().fileExists(atPath: url.path) else { continue } - guard self.anthropicOAuthStatus(at: url).isConnected else { continue } - guard let storage = self.loadStorage(at: url) else { continue } - do { - try self.saveStorage(storage) - return url - } catch { - continue - } - } - - return nil - } - - static func anthropicOAuthStatus() -> AnthropicOAuthStatus { - self.anthropicOAuthStatus(at: self.oauthURL()) - } - - static func hasAnthropicOAuth() -> Bool { - self.anthropicOAuthStatus().isConnected - } - - static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { - guard FileManager().fileExists(atPath: url.path) else { return .missingFile } - - guard let data = try? Data(contentsOf: url) else { return .unreadableFile } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } - guard let storage = json as? [String: Any] else { return .invalidJSON } - guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } - guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } - - let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) - let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) - guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } - - let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] - let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { - ms - } else if let number = expiresAny as? NSNumber { - number.int64Value - } else if let ms = expiresAny as? Double { - Int64(ms) - } else { - nil - } - - return .connected(expiresAtMs: expiresAtMs) - } - - static func loadAnthropicOAuthRefreshToken() -> String? { - let url = self.oauthURL() - guard let storage = self.loadStorage(at: url) else { return nil } - guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } - let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) - return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func firstString(in dict: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String { return value } - } - return nil - } - - private static func loadStorage(at url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } - return json as? [String: Any] - } - - static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { - let url = self.oauthURL() - let existing: [String: Any] = self.loadStorage(at: url) ?? [:] - - var updated = existing - updated[self.providerKey] = [ - "type": creds.type, - "refresh": creds.refresh, - "access": creds.access, - "expires": creds.expires, - ] - - try self.saveStorage(updated) - } - - private static func saveStorage(_ storage: [String: Any]) throws { - let dir = self.oauthDir() - try FileManager().createDirectory( - at: dir, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - - let url = self.oauthURL() - let data = try JSONSerialization.data( - withJSONObject: storage, - options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } -} - -extension Data { - fileprivate func base64URLEncodedString() -> String { - self.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift deleted file mode 100644 index 2a88898c34df..000000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -enum AnthropicOAuthCodeState { - struct Parsed: Equatable { - let code: String - let state: String - } - - /// Extracts a `code#state` payload from arbitrary text. - /// - /// Supports: - /// - raw `code#state` - /// - OAuth callback URLs containing `code=` and `state=` query params - /// - surrounding text/backticks from instructions pages - static func extract(from raw: String) -> String? { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "`")) - if text.isEmpty { return nil } - - if let fromURL = self.extractFromURL(text) { return fromURL } - if let fromToken = self.extractFromToken(text) { return fromToken } - return nil - } - - static func parse(from raw: String) -> Parsed? { - guard let extracted = self.extract(from: raw) else { return nil } - let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init) - let code = parts.first ?? "" - let state = parts.count > 1 ? parts[1] : "" - guard !code.isEmpty, !state.isEmpty else { return nil } - return Parsed(code: code, state: state) - } - - private static func extractFromURL(_ text: String) -> String? { - // Users might copy the callback URL from the browser address bar. - guard let components = URLComponents(string: text), - let items = components.queryItems, - let code = items.first(where: { $0.name == "code" })?.value, - let state = items.first(where: { $0.name == "state" })?.value, - !code.isEmpty, !state.isEmpty - else { return nil } - - return "\(code)#\(state)" - } - - private static func extractFromToken(_ text: String) -> String? { - // Base64url-ish tokens; keep this fairly strict to avoid false positives. - let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"# - guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } - - let range = NSRange(text.startIndex.. Bool + { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard dictionary[key] != nil else { return false } + dictionary.removeValue(forKey: key) + return true + } + if (dictionary[key] as? String) != trimmed { + dictionary[key] = trimmed + return true + } + return false + } + + private static func updatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + { + var remote = current + var changed = false + + switch transport { + case .direct: + changed = Self.updateGatewayString( + &remote, + key: "transport", + value: RemoteTransport.direct.rawValue) || changed + + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { + changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed + } + + case .ssh: + changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed + changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed + } + + return (remote, changed) + } + private func startConfigWatcher() { let configUrl = OpenClawConfigFile.url() self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in @@ -470,69 +534,16 @@ final class AppState { } if connectionMode == .remote { - var remote = gateway["remote"] as? [String: Any] ?? [:] - var remoteChanged = false - - if remoteTransport == .direct { - let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedUrl.isEmpty { - if remote["url"] != nil { - remote.removeValue(forKey: "url") - remoteChanged = true - } - } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { - if (remote["url"] as? String) != normalizedUrl { - remote["url"] = normalizedUrl - remoteChanged = true - } - } - if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { - remote["transport"] = RemoteTransport.direct.rawValue - remoteChanged = true - } - } else { - if remote["transport"] != nil { - remote.removeValue(forKey: "transport") - remoteChanged = true - } - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl - remoteChanged = true - } - } - - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget - remoteChanged = true - } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity - remoteChanged = true - } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true - } - } - - if remoteChanged { - gateway["remote"] = remote + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity) + if updated.changed { + gateway["remote"] = updated.remote changed = true } } diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift index abbddb245887..6c01628144b0 100644 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -53,6 +53,15 @@ final class AudioInputDeviceObserver { return output } + /// Returns true when the system default input device exists and is alive with input channels. + /// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs + /// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic + /// is disconnected. + static func hasUsableDefaultInputDevice() -> Bool { + guard let uid = self.defaultInputDeviceUID() else { return false } + return self.aliveInputDeviceUIDs().contains(uid) + } + static func defaultInputDeviceSummary() -> String { let systemObject = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index c17f64e30e73..cacfac2f0684 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -246,15 +246,17 @@ enum CommandResolver { return ssh } - let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) + let root = self.projectRoot() + if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { + return [openclawPath, subcommand] + extraArgs + } + if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { + return [openclawPath, subcommand] + extraArgs + } + let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) switch runtimeResult { case let .success(runtime): - let root = self.projectRoot() - if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { - return [openclawPath, subcommand] + extraArgs - } - if let entry = self.gatewayEntrypoint(in: root) { return self.makeRuntimeCommand( runtime: runtime, @@ -262,19 +264,21 @@ enum CommandResolver { subcommand: subcommand, extraArgs: extraArgs) } - if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { - // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. - return [pnpm, "--silent", "openclaw", subcommand] + extraArgs - } - if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { - return [openclawPath, subcommand] + extraArgs - } + case .failure: + break + } + if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { + // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. + return [pnpm, "--silent", "openclaw", subcommand] + extraArgs + } + + switch runtimeResult { + case .success: let missingEntry = """ openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. """ return self.errorCommand(with: missingEntry) - case let .failure(error): return self.runtimeErrorCommand(error) } diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift index 2dd720741bbb..ad40d2c38037 100644 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -8,7 +8,7 @@ enum ExecAllowlistMatcher { for entry in entries { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { - case .valid(let pattern): + case let .valid(pattern): let target = resolvedPath ?? rawExecutable if self.matches(pattern: pattern, target: target) { return entry } case .invalid: diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index 7bb05aff0c9e..c7d9d0928e14 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -28,7 +28,8 @@ enum ExecApprovalEvaluator { let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId) let security = approvals.agent.security let ask = approvals.agent.ask - let env = HostEnvSanitizer.sanitize(overrides: envOverrides) + let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper + let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper) let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( command: command, diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 08567cd0b09a..73aa3899d824 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -439,9 +439,9 @@ enum ExecApprovalsStore { static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { let normalizedPattern: String switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let validPattern): + case let .valid(validPattern): normalizedPattern = validPattern - case .invalid(let reason): + case let .invalid(reason): return reason } @@ -571,7 +571,7 @@ enum ExecApprovalsStore { private static func normalizedPattern(_ pattern: String?) -> String? { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalized): + case let .valid(normalized): return normalized.lowercased() case .invalid(.empty): return nil @@ -587,7 +587,7 @@ enum ExecApprovalsStore { let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): return ExecAllowlistEntry( id: entry.id, pattern: pattern, @@ -596,7 +596,7 @@ enum ExecApprovalsStore { lastResolvedPath: normalizedResolved) case .invalid: switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { - case .valid(let migratedPattern): + case let .valid(migratedPattern): return ExecAllowlistEntry( id: entry.id, pattern: migratedPattern, @@ -629,7 +629,7 @@ enum ExecApprovalsStore { let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): normalized.append( ExecAllowlistEntry( id: migrated.id, @@ -637,7 +637,7 @@ enum ExecApprovalsStore { lastUsedAt: migrated.lastUsedAt, lastUsedCommand: migrated.lastUsedCommand, lastResolvedPath: normalizedResolvedPath)) - case .invalid(let reason): + case let .invalid(reason): if dropInvalid { rejected.append( ExecAllowlistRejectedEntry( diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 362a7da01d88..1417589ae4a5 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -38,7 +38,7 @@ private struct ExecHostSocketRequest: Codable { var requestJson: String } -private struct ExecHostRequest: Codable { +struct ExecHostRequest: Codable { var command: [String] var rawCommand: String? var cwd: String? @@ -59,7 +59,7 @@ private struct ExecHostRunResult: Codable { var error: String? } -private struct ExecHostError: Codable { +struct ExecHostError: Codable, Error { var code: String var message: String var reason: String? @@ -353,38 +353,28 @@ private enum ExecHostExecutor { private typealias ExecApprovalContext = ExecApprovalEvaluation static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { - let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !command.isEmpty else { - return self.errorResponse( - code: "INVALID_REQUEST", - message: "command required", - reason: "invalid") - } - - let context = await self.buildContext(request: request, command: command) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } - - let approvalDecision = request.approvalDecision - if approvalDecision == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - } - - var approvedByAsk = approvalDecision != nil - if ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow), - approvalDecision == nil + let validatedRequest: ExecHostValidatedRequest + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success(let request): + validatedRequest = request + case .failure(let error): + return self.errorResponse(error) + } + + let context = await self.buildContext( + request: request, + command: validatedRequest.command, + rawCommand: validatedRequest.displayCommand) + + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: request.approvalDecision) { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( command: context.displayCommand, @@ -396,33 +386,35 @@ private enum ExecHostExecutor { resolvedPath: context.resolution?.resolvedPath, sessionKey: request.sessionKey)) + let followupDecision: ExecApprovalDecision switch decision { case .deny: - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") + followupDecision = .deny case .allowAlways: - approvedByAsk = true + followupDecision = .allowAlways self.persistAllowlistEntry(decision: decision, context: context) case .allowOnce: - approvedByAsk = true + followupDecision = .allowOnce } - } - - self.persistAllowlistEntry(decision: approvalDecision, context: context) - if context.security == .allowlist, - !context.allowlistSatisfied, - !context.skillAllow, - !approvedByAsk - { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: allowlist miss", - reason: "allowlist-miss") + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: followupDecision) + { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: + return self.errorResponse( + code: "INVALID_REQUEST", + message: "unexpected approval state", + reason: "invalid") + } } + self.persistAllowlistEntry(decision: request.approvalDecision, context: context) + if context.allowlistSatisfied { var seenPatterns = Set() for (idx, match) in context.allowlistMatches.enumerated() { @@ -445,16 +437,20 @@ private enum ExecHostExecutor { } return await self.runCommand( - command: command, + command: validatedRequest.command, cwd: request.cwd, env: context.env, timeoutMs: request.timeoutMs) } - private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { + private static func buildContext( + request: ExecHostRequest, + command: [String], + rawCommand: String?) async -> ExecApprovalContext + { await ExecApprovalEvaluator.evaluate( command: command, - rawCommand: request.rawCommand, + rawCommand: rawCommand, cwd: request.cwd, envOverrides: request.env, agentId: request.agentId) @@ -514,6 +510,17 @@ private enum ExecHostExecutor { return self.successResponse(payload) } + private static func errorResponse( + _ error: ExecHostError) -> ExecHostResponse + { + ExecHostResponse( + type: "response", + id: UUID().uuidString, + ok: false, + payload: nil, + error: error) + } + private static func errorResponse( code: String, message: String, diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 8910163456f3..843062b2470d 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -25,7 +25,7 @@ struct ExecCommandResolution: Sendable { cwd: String?, env: [String: String]?) -> [ExecCommandResolution] { - let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand) + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand) if shell.isWrapper { guard let shellCommand = shell.command, let segments = self.splitShellCommandChain(shellCommand) @@ -54,7 +54,8 @@ struct ExecCommandResolution: Sendable { } static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { - guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) + guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) @@ -101,47 +102,6 @@ struct ExecCommandResolution: Sendable { return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) } - private static func basenameLower(_ token: String) -> String { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - let normalized = trimmed.replacingOccurrences(of: "\\", with: "/") - return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased() - } - - private static func extractShellCommandFromArgv( - command: [String], - rawCommand: String?) -> (isWrapper: Bool, command: String?) - { - guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { - return (false, nil) - } - let base0 = self.basenameLower(token0) - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw - - if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) { - let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" - guard flag == "-lc" || flag == "-c" else { return (false, nil) } - let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" - let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload) - return (true, normalized) - } - - if base0 == "cmd.exe" || base0 == "cmd" { - guard let idx = command - .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) - else { - return (false, nil) - } - let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") - let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload) - return (true, normalized) - } - - return (false, nil) - } - private enum ShellTokenContext { case unquoted case doubleQuoted diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift new file mode 100644 index 000000000000..ebb8965e7552 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -0,0 +1,108 @@ +import Foundation + +enum ExecCommandToken { + static func basenameLower(_ token: String) -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let normalized = trimmed.replacingOccurrences(of: "\\", with: "/") + return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased() + } +} + +enum ExecEnvInvocationUnwrapper { + static let maxWrapperDepth = 4 + + private static let optionsWithValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) + + private static func isEnvAssignment(_ token: String) -> Bool { + let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"# + return token.range(of: pattern, options: .regularExpression) != nil + } + + static func unwrap(_ command: [String]) -> [String]? { + var idx = 1 + var expectsOptionValue = false + while idx < command.count { + let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + idx += 1 + continue + } + if token.hasPrefix("-"), token != "-" { + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.flagOptions.contains(flag) { + idx += 1 + continue + } + if self.optionsWithValue.contains(flag) { + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if lower.hasPrefix("-u") || + lower.hasPrefix("-c") || + lower.hasPrefix("-s") || + lower.hasPrefix("--unset=") || + lower.hasPrefix("--chdir=") || + lower.hasPrefix("--split-string=") || + lower.hasPrefix("--default-signal=") || + lower.hasPrefix("--ignore-signal=") || + lower.hasPrefix("--block-signal=") + { + idx += 1 + continue + } + return nil + } + break + } + guard idx < command.count else { return nil } + return Array(command[idx...]) + } + + static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { + var current = command + var depth = 0 + while depth < self.maxWrapperDepth { + guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + break + } + guard ExecCommandToken.basenameLower(token) == "env" else { + break + } + guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else { + break + } + current = unwrapped + depth += 1 + } + return current + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift new file mode 100644 index 000000000000..fe38d7ea18f2 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -0,0 +1,84 @@ +import Foundation + +struct ExecHostValidatedRequest { + let command: [String] + let displayCommand: String +} + +enum ExecHostPolicyDecision { + case deny(ExecHostError) + case requiresPrompt + case allow(approvedByAsk: Bool) +} + +enum ExecHostRequestEvaluator { + static func validateRequest(_ request: ExecHostRequest) -> Result { + let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !command.isEmpty else { + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: "command required", + reason: "invalid")) + } + + let validatedCommand = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: request.rawCommand) + switch validatedCommand { + case .ok(let resolved): + return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand)) + case .invalid(let message): + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: message, + reason: "invalid")) + } + } + + static func evaluate( + context: ExecApprovalEvaluation, + approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision + { + if context.security == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DISABLED: security=deny", + reason: "security=deny")) + } + + if approvalDecision == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied")) + } + + let approvedByAsk = approvalDecision != nil + let requiresPrompt = ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow) && approvalDecision == nil + if requiresPrompt { + return .requiresPrompt + } + + if context.security == .allowlist, + !context.allowlistSatisfied, + !context.skillAllow, + !approvedByAsk + { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: allowlist miss", + reason: "allowlist-miss")) + } + + return .allow(approvedByAsk: approvedByAsk) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift new file mode 100644 index 000000000000..06851a7d0657 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -0,0 +1,108 @@ +import Foundation + +enum ExecShellWrapperParser { + struct ParsedShellWrapper { + let isWrapper: Bool + let command: String? + + static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil) + } + + private enum Kind { + case posix + case cmd + case powershell + } + + private struct WrapperSpec { + let kind: Kind + let names: Set + } + + private static let posixInlineFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineFlags = Set(["-c", "-command", "--command"]) + + private static let wrapperSpecs: [WrapperSpec] = [ + WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]), + WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), + WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), + ] + + static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw + return self.extract(command: command, preferredRaw: preferredRaw, depth: 0) + } + + private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper { + guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else { + return .notWrapper + } + guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { + return .notWrapper + } + + let base0 = ExecCommandToken.basenameLower(token0) + if base0 == "env" { + guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else { + return .notWrapper + } + return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1) + } + + guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else { + return .notWrapper + } + guard let payload = self.extractPayload(command: command, spec: spec) else { + return .notWrapper + } + let normalized = preferredRaw ?? payload + return ParsedShellWrapper(isWrapper: true, command: normalized) + } + + private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { + switch spec.kind { + case .posix: + self.extractPosixInlineCommand(command) + case .cmd: + self.extractCmdInlineCommand(command) + case .powershell: + self.extractPowerShellInlineCommand(command) + } + } + + private static func extractPosixInlineCommand(_ command: [String]) -> String? { + let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" + guard self.posixInlineFlags.contains(flag.lowercased()) else { + return nil + } + let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" + return payload.isEmpty ? nil : payload + } + + private static func extractCmdInlineCommand(_ command: [String]) -> String? { + guard let idx = command + .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) + else { + return nil + } + let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") + let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } + + private static func extractPowerShellInlineCommand(_ command: [String]) -> String? { + for idx in 1.. String? { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizeExecutableToken(_ token: String) -> String { + let base = ExecCommandToken.basenameLower(token) + if base.hasSuffix(".exe") { + return String(base.dropLast(4)) + } + return base + } + + private static func isEnvAssignment(_ token: String) -> Bool { + token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil + } + + private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { + self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) } + } + + private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { + var idx = 1 + var expectsOptionValue = false + var usesModifiers = false + + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + usesModifiers = true + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + usesModifiers = true + idx += 1 + continue + } + if !token.hasPrefix("-") || token == "-" { + break + } + + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.envFlagOptions.contains(flag) { + usesModifiers = true + idx += 1 + continue + } + if self.envOptionsWithValue.contains(flag) { + usesModifiers = true + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if self.hasEnvInlineValuePrefix(lower) { + usesModifiers = true + idx += 1 + continue + } + return nil + } + + if expectsOptionValue { + return nil + } + guard idx < argv.count else { + return nil + } + return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers) + } + + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return nil + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.shellMultiplexerWrapperNames.contains(wrapper) else { + return nil + } + + var appletIndex = 1 + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return nil + } + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty else { + return nil + } + let normalizedApplet = self.normalizeExecutableToken(applet) + guard self.shellWrapperNames.contains(normalizedApplet) else { + return nil + } + return Array(argv[appletIndex...]) + } + + private static func hasEnvManipulationBeforeShellWrapper( + _ argv: [String], + depth: Int = 0, + envManipulationSeen: Bool = false) -> Bool + { + if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth { + return false + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return false + } + + let normalized = self.normalizeExecutableToken(token0) + if normalized == "env" { + guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else { + return false + } + return self.hasEnvManipulationBeforeShellWrapper( + envUnwrap.argv, + depth: depth + 1, + envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers) + } + + if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) { + return self.hasEnvManipulationBeforeShellWrapper( + shellMultiplexer, + depth: depth + 1, + envManipulationSeen: envManipulationSeen) + } + + guard self.shellWrapperNames.contains(normalized) else { + return false + } + guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else { + return false + } + return envManipulationSeen + } + + private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool { + let wrapperArgv = self.unwrapShellWrapperArgv(argv) + guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else { + return false + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else { + return false + } + + let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } else { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + guard let inlineCommandIndex else { + return false + } + let start = inlineCommandIndex + 1 + guard start < wrapperArgv.count else { + return false + } + return wrapperArgv[start...].contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] { + var current = argv + for _ in 0.., + allowCombinedC: Bool) -> Int? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return idx + 1 < argv.count ? idx + 1 : nil + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !inline.isEmpty { + return idx + } + return idx + 1 < argv.count ? idx + 1 : nil + } + idx += 1 + } + return nil + } + + private static func combinedCommandInlineOffset(_ token: String) -> Int? { + let chars = Array(token.lowercased()) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + if chars.dropFirst().contains("-") { + return nil + } + guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { + return nil + } + return commandIndex + 1 + } + + private static func extractShellInlinePayload( + _ argv: [String], + normalizedWrapper: String) -> String? + { + if normalizedWrapper == "cmd" { + return self.extractCmdInlineCommand(argv) + } + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + + private static func extractInlineCommandByFlags( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + if let inlineValue = self.trimmedNonEmpty(inline) { + return inlineValue + } + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + idx += 1 + } + return nil + } + + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let tailIndex = idx + 1 + guard tailIndex < argv.count else { + return nil + } + let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } +} diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 60cfdfb1d737..4dae858771cc 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -304,8 +304,7 @@ struct GeneralSettings: View { .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." - ) + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -549,8 +548,7 @@ extension GeneralSettings { } guard Self.isValidWsUrl(trimmedUrl) else { self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" - ) + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") return } } else { diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index b387c36d3a4d..e1c4f5b8531e 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -1,29 +1,20 @@ import Foundation enum HostEnvSanitizer { - /// Keep in sync with src/infra/host-env-security-policy.json. + /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs. /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. - private static let blockedKeys: Set = [ - "NODE_OPTIONS", - "NODE_PATH", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYLIB", - "RUBYOPT", - "BASH_ENV", - "ENV", - "SHELL", - "GCONV_PATH", - "IFS", - "SSLKEYLOGFILE", - ] - - private static let blockedPrefixes: [String] = [ - "DYLD_", - "LD_", - "BASH_FUNC_", + private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys + private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes + private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys + private static let shellWrapperAllowedOverrideKeys: Set = [ + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", ] private static func isBlocked(_ upperKey: String) -> Bool { @@ -31,7 +22,20 @@ enum HostEnvSanitizer { return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) } - static func sanitize(overrides: [String: String]?) -> [String: String] { + private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var filtered: [String: String] = [:] + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + if self.shellWrapperAllowedOverrideKeys.contains(key.uppercased()) { + filtered[key] = value + } + } + return filtered.isEmpty ? nil : filtered + } + + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { var merged: [String: String] = [:] for (rawKey, value) in ProcessInfo.processInfo.environment { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -41,14 +45,19 @@ enum HostEnvSanitizer { merged[key] = value } - guard let overrides else { return merged } - for (rawKey, value) in overrides { + let effectiveOverrides = shellWrapper + ? self.filterOverridesForShellWrapper(overrides) + : overrides + + guard let effectiveOverrides else { return merged } + for (rawKey, value) in effectiveOverrides { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } let upper = key.uppercased() // PATH is part of the security boundary (command resolution + safe-bin checks). Never // allow request-scoped PATH overrides from agents/gateways. if upper == "PATH" { continue } + if self.blockedOverrideKeys.contains(upper) { continue } if self.isBlocked(upper) { continue } merged[key] = value } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift new file mode 100644 index 000000000000..b126d03de212 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -0,0 +1,38 @@ +// Generated file. Do not edit directly. +// Source: src/infra/host-env-security-policy.json +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write + +import Foundation + +enum HostEnvSecurityPolicy { + static let blockedKeys: Set = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "GIT_EXTERNAL_DIFF", + "SHELL", + "SHELLOPTS", + "PS4", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE" + ] + + static let blockedOverrideKeys: Set = [ + "HOME", + "ZDOTDIR" + ] + + static let blockedPrefixes: [String] = [ + "DYLD_", + "LD_", + "BASH_FUNC_" + ] +} diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 00e2a9be0a63..d7ab72ce86f6 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -431,7 +431,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } } -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} +extension SparkleUpdaterController: SPUUpdaterDelegate {} private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 37fd6ca25052..eb6271d0a8ce 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -446,6 +446,8 @@ extension MenuSessionsInjector { private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu { let menu = NSMenu() + // Keep submenu delegate nil: reusing the status-menu delegate here causes + // recursive reinjection whenever this submenu is opened. for row in rows { let item = NSMenuItem() item.tag = self.tag @@ -493,7 +495,6 @@ extension MenuSessionsInjector { guard !summary.daily.isEmpty else { return nil } let menu = NSMenu() - menu.delegate = self let chartView = CostUsageHistoryMenuView(summary: summary, width: width) let hosting = NSHostingView(rootView: AnyView(chartView)) @@ -1226,6 +1227,12 @@ extension MenuSessionsInjector { self.usageCacheUpdatedAt = Date() } + func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) { + self.cachedCostSummary = summary + self.cachedCostErrorText = errorText + self.costCacheUpdatedAt = Date() + } + func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift index e35057d28cfa..81e06abda2df 100644 --- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift +++ b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift @@ -14,6 +14,13 @@ actor MicLevelMonitor { if self.running { return } self.logger.info( "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } let engine = AVAudioEngine() self.engine = engine let input = engine.inputNode diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index b8a6377b419e..4eae7e092b02 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -1,5 +1,4 @@ import AppKit -import Combine import Observation import OpenClawChatUI import OpenClawDiscovery @@ -69,22 +68,6 @@ struct OnboardingView: View { @State var workspacePath: String = "" @State var workspaceStatus: String? @State var workspaceApplying = false - @State var anthropicAuthPKCE: AnthropicOAuth.PKCE? - @State var anthropicAuthCode: String = "" - @State var anthropicAuthStatus: String? - @State var anthropicAuthBusy = false - @State var anthropicAuthConnected = false - @State var anthropicAuthVerifying = false - @State var anthropicAuthVerified = false - @State var anthropicAuthVerificationAttempted = false - @State var anthropicAuthVerificationFailed = false - @State var anthropicAuthVerifiedAt: Date? - @State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile - @State var anthropicAuthAutoDetectClipboard = true - @State var anthropicAuthAutoConnectClipboard = true - @State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount - @State var monitoringAuth = false - @State var authMonitorTask: Task? @State var needsBootstrap = false @State var didAutoKickoff = false @State var showAdvancedConnection = false @@ -104,19 +87,9 @@ struct OnboardingView: View { let pageWidth: CGFloat = Self.windowWidth let contentHeight: CGFloat = 460 let connectionPageIndex = 1 - let anthropicAuthPageIndex = 2 let wizardPageIndex = 3 let onboardingChatPageIndex = 8 - static let clipboardPoll: AnyPublisher = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - let permissionsPageIndex = 5 static func pageOrder( for mode: AppState.ConnectionMode, diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index bcd5bd6d44d2..a521926ddb99 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -78,70 +78,4 @@ extension OnboardingView { self.copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } } - - func startAnthropicOAuth() { - guard !self.anthropicAuthBusy else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.anthropicAuthPKCE = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - func finishAnthropicOAuth() async { - guard !self.anthropicAuthBusy else { return } - guard let pkce = self.anthropicAuthPKCE else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { - self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude." - } catch { - self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" - } - } - - func pollAnthropicClipboardIfNeeded() { - guard self.currentPage == self.anthropicAuthPageIndex else { return } - guard self.anthropicAuthPKCE != nil else { return } - guard !self.anthropicAuthBusy else { return } - guard self.anthropicAuthAutoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } - self.anthropicAuthLastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.anthropicAuthCode != next { - self.anthropicAuthCode = next - self.anthropicAuthStatus = "Detected `code#state` from clipboard." - } - - guard self.anthropicAuthAutoConnectClipboard else { return } - Task { await self.finishAnthropicOAuth() } - } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index ce87e211ce44..9b0e45e205c6 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -53,7 +53,6 @@ extension OnboardingView { .onDisappear { self.stopPermissionMonitoring() self.stopDiscovery() - self.stopAuthMonitoring() Task { await self.onboardingWizard.cancelIfRunning() } } .task { @@ -61,7 +60,6 @@ extension OnboardingView { self.refreshCLIStatus() await self.loadWorkspaceDefaults() await self.ensureDefaultWorkspace() - self.refreshAnthropicOAuthStatus() self.refreshBootstrapStatus() self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID() } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index dfbdf91d44d8..efe37f31673c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -47,7 +47,6 @@ extension OnboardingView { func updateMonitoring(for pageIndex: Int) { self.updatePermissionMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex) - self.updateAuthMonitoring(for: pageIndex) self.maybeKickoffOnboardingChat(for: pageIndex) } @@ -63,33 +62,6 @@ extension OnboardingView { self.gatewayDiscovery.stop() } - func updateAuthMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local - if shouldMonitor, !self.monitoringAuth { - self.monitoringAuth = true - self.startAuthMonitoring() - } else if !shouldMonitor, self.monitoringAuth { - self.stopAuthMonitoring() - } - } - - func startAuthMonitoring() { - self.refreshAnthropicOAuthStatus() - self.authMonitorTask?.cancel() - self.authMonitorTask = Task { - while !Task.isCancelled { - await MainActor.run { self.refreshAnthropicOAuthStatus() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - func stopAuthMonitoring() { - self.monitoringAuth = false - self.authMonitorTask?.cancel() - self.authMonitorTask = nil - } - func installCLI() async { guard !self.installingCLI else { return } self.installingCLI = true @@ -125,54 +97,4 @@ extension OnboardingView { expected: expected) } } - - func refreshAnthropicOAuthStatus() { - _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - let previous = self.anthropicAuthDetectedStatus - let status = OpenClawOAuthStore.anthropicOAuthStatus() - self.anthropicAuthDetectedStatus = status - self.anthropicAuthConnected = status.isConnected - - if previous != status { - self.anthropicAuthVerified = false - self.anthropicAuthVerificationAttempted = false - self.anthropicAuthVerificationFailed = false - self.anthropicAuthVerifiedAt = nil - } - } - - @MainActor - func verifyAnthropicOAuthIfNeeded(force: Bool = false) async { - guard self.state.connectionMode == .local else { return } - guard self.anthropicAuthDetectedStatus.isConnected else { return } - if self.anthropicAuthVerified, !force { return } - if self.anthropicAuthVerifying { return } - if self.anthropicAuthVerificationAttempted, !force { return } - - self.anthropicAuthVerificationAttempted = true - self.anthropicAuthVerifying = true - self.anthropicAuthVerificationFailed = false - defer { self.anthropicAuthVerifying = false } - - guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else { - self.anthropicAuthStatus = "OAuth verification failed: missing refresh token." - self.anthropicAuthVerificationFailed = true - return - } - - do { - let updated = try await AnthropicOAuth.refresh(refreshToken: refresh) - try OpenClawOAuthStore.saveAnthropicOAuth(updated) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthVerified = true - self.anthropicAuthVerifiedAt = Date() - self.anthropicAuthVerificationFailed = false - self.anthropicAuthStatus = "OAuth detected and verified." - } catch { - self.anthropicAuthVerified = false - self.anthropicAuthVerifiedAt = nil - self.anthropicAuthVerificationFailed = true - self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)" - } - } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5b05ab164c2d..4f942dfe8a4f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -12,8 +12,6 @@ extension OnboardingView { self.welcomePage() case 1: self.connectionPage() - case 2: - self.anthropicAuthPage() case 3: self.wizardPage() case 5: @@ -87,19 +85,9 @@ extension OnboardingView { self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { - let localSubtitle: String = { - guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." - } - let base = probe.expected - ? "Existing gateway detected" - : "Port \(probe.port) already in use" - let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" - return "\(base)\(command). Will attach." - }() self.connectionChoiceButton( title: "This Mac", - subtitle: localSubtitle, + subtitle: self.localGatewaySubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() @@ -107,50 +95,7 @@ extension OnboardingView { Divider().padding(.vertical, 4) - HStack(spacing: 8) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.gatewayDiscovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - if self.gatewayDiscovery.gateways.isEmpty { - ProgressView().controlSize(.small) - Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) - } - .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") - } - Spacer(minLength: 0) - } - - if self.gatewayDiscovery.gateways.isEmpty { - Text("Searching for nearby gateways…") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } else { - VStack(alignment: .leading, spacing: 6) { - Text("Nearby gateways") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in - self.connectionChoiceButton( - title: gateway.displayName, - subtitle: self.gatewaySubtitle(for: gateway), - selected: self.isSelectedGateway(gateway)) - { - self.selectRemoteGateway(gateway) - } - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } + self.gatewayDiscoverySection() self.connectionChoiceButton( title: "Configure later", @@ -160,104 +105,168 @@ extension OnboardingView { self.selectUnconfiguredGateway() } - Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.showAdvancedConnection.toggle() + self.advancedConnectionSection() + } + } + } + } + + private var localGatewaySubtitle: String { + guard let probe = self.localGatewayProbe else { + return "Gateway starts automatically on this Mac." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + } + + @ViewBuilder + private func gatewayDiscoverySection() -> some View { + HStack(spacing: 8) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.gatewayDiscovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.gatewayDiscovery.gateways.isEmpty { + ProgressView().controlSize(.small) + Button("Refresh") { + self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + } + .buttonStyle(.link) + .help("Retry Tailscale discovery (DNS-SD).") + } + Spacer(minLength: 0) + } + + if self.gatewayDiscovery.gateways.isEmpty { + Text("Searching for nearby gateways…") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Nearby gateways") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in + self.connectionChoiceButton( + title: gateway.displayName, + subtitle: self.gatewaySubtitle(for: gateway), + selected: self.isSelectedGateway(gateway)) + { + self.selectRemoteGateway(gateway) + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + + @ViewBuilder + private func advancedConnectionSection() -> some View { + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.showAdvancedConnection.toggle() + } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } + } + .buttonStyle(.link) + + if self.showAdvancedConnection { + let labelWidth: CGFloat = 110 + let fieldWidth: CGFloat = 320 + + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote + .pickerStyle(.segmented) + .frame(width: fieldWidth) + } + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) } } - .buttonStyle(.link) - - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 - - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(width: fieldWidth) - } - if self.state.remoteTransport == .direct { - GridRow { - Text("Gateway URL") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - if self.state.remoteTransport == .ssh { - GridRow { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if let message = CommandResolver - .sshTargetValidationMessage(self.state.remoteTarget) - { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .frame(width: fieldWidth, alignment: .leading) - } - } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/OpenClaw.app/.../openclaw", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + if let message = CommandResolver + .sshTargetValidationMessage(self.state.remoteTarget) + { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(width: fieldWidth, alignment: .leading) } - - Text(self.state.remoteTransport == .direct - ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." - : "Tip: keep Tailscale enabled so your gateway stays reachable.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) } - .transition(.opacity.combined(with: .move(edge: .top))) + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/OpenClaw.app/.../openclaw", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } } + + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) } + .transition(.opacity.combined(with: .move(edge: .top))) } } @@ -329,170 +338,6 @@ extension OnboardingView { .buttonStyle(.plain) } - func anthropicAuthPage() -> some View { - self.onboardingPage { - Text("Connect Claude") - .font(.largeTitle.weight(.semibold)) - Text("Give your model the token it needs!") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 12, padding: 16) { - HStack(alignment: .center, spacing: 10) { - Circle() - .fill(self.anthropicAuthVerified ? Color.green : Color.orange) - .frame(width: 10, height: 10) - Text( - self.anthropicAuthConnected - ? (self.anthropicAuthVerified - ? "Claude connected (OAuth) — verified" - : "Claude connected (OAuth)") - : "Not connected yet") - .font(.headline) - Spacer() - } - - if self.anthropicAuthConnected, self.anthropicAuthVerifying { - Text("Verifying OAuth…") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if !self.anthropicAuthConnected { - Text(self.anthropicAuthDetectedStatus.shortDescription) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt { - Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Text( - "This lets OpenClaw use Claude immediately. Credentials are stored at " + - "`~/.openclaw/credentials/oauth.json` (owner-only).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 12) { - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - - Button("Refresh") { - self.refreshAnthropicOAuthStatus() - } - .buttonStyle(.bordered) - } - - Divider().padding(.vertical, 2) - - HStack(spacing: 12) { - if !self.anthropicAuthVerified { - if self.anthropicAuthConnected { - Button("Verify") { - Task { await self.verifyAnthropicOAuthIfNeeded(force: true) } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - - if self.anthropicAuthVerificationFailed { - Button("Re-auth (OAuth)") { - self.startAnthropicOAuth() - } - .buttonStyle(.bordered) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - } - } else { - Button { - self.startAnthropicOAuth() - } label: { - if self.anthropicAuthBusy { - ProgressView() - } else { - Text("Open Claude sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy) - } - } - } - - if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste the `code#state` value") - .font(.headline) - TextField("code#state", text: self.$anthropicAuthCode) - .textFieldStyle(.roundedBorder) - - Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Button("Connect") { - Task { await self.finishAnthropicOAuth() } - } - .buttonStyle(.bordered) - .disabled( - self.anthropicAuthBusy || - self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - .onReceive(Self.clipboardPoll) { _ in - self.pollAnthropicClipboardIfNeeded() - } - } - - self.onboardingCard(spacing: 8, padding: 12) { - Text("API key (advanced)") - .font(.headline) - Text( - "You can also use an Anthropic API key, but this UI is instructions-only for now " + - "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .shadow(color: .clear, radius: 0) - .background(Color.clear) - - if let status = self.anthropicAuthStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .task { await self.verifyAnthropicOAuthIfNeeded() } - } - func permissionsPage() -> some View { self.onboardingPage { Text("Grant permissions") diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index cf8c3d0c78f6..2bd9c525ad4a 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -37,18 +37,9 @@ extension OnboardingView { view.cliStatus = "Installed" view.workspacePath = "/tmp/openclaw" view.workspaceStatus = "Saved workspace" - view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - view.anthropicAuthCode = "code#state" - view.anthropicAuthStatus = "Connected" - view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) - view.anthropicAuthConnected = true - view.anthropicAuthAutoDetectClipboard = false - view.anthropicAuthAutoConnectClipboard = false - view.state.connectionMode = .local _ = view.welcomePage() _ = view.connectionPage() - _ = view.anthropicAuthPage() _ = view.wizardPage() _ = view.permissionsPage() _ = view.cliPage() diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift index 1895b2af94f7..7538f846b890 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -13,8 +13,10 @@ extension OnboardingView { guard self.state.connectionMode == .local else { return } let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: + let safety = AgentWorkspace.bootstrapSafety(for: url) + if let reason = safety.unsafeReason { + self.workspaceStatus = "Workspace not touched: \(reason)" + } else { do { _ = try AgentWorkspace.bootstrap(workspaceURL: url) if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -23,8 +25,6 @@ extension OnboardingView { } catch { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } - case let .unsafe (reason): - self.workspaceStatus = "Workspace not touched: \(reason)" } self.refreshBootstrapStatus() } @@ -54,7 +54,7 @@ extension OnboardingView { do { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { + if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason { self.workspaceStatus = "Workspace not created: \(reason)" return } diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index e7ca1ad54878..f1eb6f463ae6 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.26 CFBundleVersion - 202602210 + 202602260 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index a6d81f50bcac..7c047e01d03c 100644 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -383,12 +383,12 @@ final class ExecApprovalsSettingsModel { func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { guard !self.isDefaultsScope else { return nil } switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) self.allowlistValidationMessage = rejected.first?.reason.message return rejected.first?.reason - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } @@ -400,9 +400,9 @@ final class ExecApprovalsSettingsModel { guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } var next = entry switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): next.pattern = normalizedPattern - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 47b041a5873e..a8d8008c6530 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -11,6 +11,7 @@ actor TalkModeRuntime { private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime") private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() @@ -184,6 +185,12 @@ actor TalkModeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + self.logger.error("talk mode: no usable audio input device") + return + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) input.removeTap(onBus: 0) @@ -792,6 +799,82 @@ extension TalkModeRuntime { let apiKey: String? } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: AnyCodable] + let normalizedPayload: Bool + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? { + if let typed = value.value as? [String: AnyCodable] { + return typed + } + if let foundation = value.value as? [String: Any] { + return foundation.mapValues(AnyCodable.init) + } + if let nsDict = value.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in nsDict { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let raw else { return [:] } + var providerMap: [String: AnyCodable] = [:] + if let typed = raw.value as? [String: AnyCodable] { + providerMap = typed + } else if let foundation = raw.value as? [String: Any] { + providerMap = foundation.mapValues(AnyCodable.init) + } else if let nsDict = raw.value as? NSDictionary { + for case let (key as String, value) in nsDict { + providerMap[key] = AnyCodable(value) + } + } else { + return [:] + } + + return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = Self.normalizedTalkProviderConfig(entry.value) + else { return } + acc[providerID] = providerConfig + } + } + + static func selectTalkProviderConfig( + _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? + { + guard let talk else { return nil } + let rawProvider = talk["provider"]?.stringValue + let rawProviders = talk["providers"] + let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + if hasNormalizedPayload { + let normalizedProviders = Self.normalizedTalkProviders(rawProviders) + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.min() ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + return TalkProviderConfigSelection( + provider: Self.defaultTalkProvider, + config: talk, + normalizedPayload: false) + } + private func fetchTalkConfig() async -> TalkRuntimeConfig { let env = ProcessInfo.processInfo.environment let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -804,13 +887,16 @@ extension TalkModeRuntime { params: ["includeSecrets": AnyCodable(true)], timeoutMs: 8000) let talk = snap.config?["talk"]?.dictionaryValue + let selection = Self.selectTalkProviderConfig(talk) + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config let ui = snap.config?["ui"]?.dictionaryValue let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await MainActor.run { AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam } - let voice = talk?["voiceId"]?.stringValue - let rawAliases = talk?["voiceAliases"]?.dictionaryValue + let voice = activeConfig?["voiceId"]?.stringValue + let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue let resolvedAliases: [String: String] = rawAliases?.reduce(into: [:]) { acc, entry in let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -818,18 +904,30 @@ extension TalkModeRuntime { guard !key.isEmpty, !value.isEmpty else { return } acc[key] = value } ?? [:] - let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = talk?["outputFormat"]?.stringValue + let outputFormat = activeConfig?["outputFormat"]?.stringValue let interrupt = talk?["interruptOnSpeech"]?.boolValue - let apiKey = talk?["apiKey"]?.stringValue - let resolvedVoice = + let apiKey = activeConfig?["apiKey"]?.stringValue + let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + } else { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) + } + let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + } else { + nil + } + if activeProvider != Self.defaultTalkProvider { + self.ttsLogger + .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") + } else if selection?.normalizedPayload == true { + self.ttsLogger.info("talk config provider elevenlabs") + } return TalkRuntimeConfig( voiceId: resolvedVoice, voiceAliases: resolvedAliases, diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift index e535ebd6616f..6eaa45e06759 100644 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -244,6 +244,14 @@ actor VoicePushToTalk { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) if self.tapInstalled { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift index ee634a628ed4..0c6ea54c90e0 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: GatewayAgentChannel = .last + var channel: GatewayAgentChannel = .webchat } @discardableResult diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift index 8e88c86d45d1..bbbed72926b5 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift @@ -185,6 +185,11 @@ private final class TranscriptNSTextView: NSTextView { self.onEscape?() return } + // Keep IME candidate confirmation behavior: Return should commit marked text first. + if isReturn, self.hasMarkedText() { + super.keyDown(with: event) + return + } if isReturn, event.modifierFlags.contains(.command) { self.onSend?() return diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift index 61f913b9da88..b7e2d329b820 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -166,6 +166,14 @@ actor VoiceWakeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) guard format.channelCount > 0, format.sampleRate > 0 else { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift index b3d0c58d90c7..063fea826ab6 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -89,6 +89,14 @@ final class VoiceWakeTester { self.logInputSelection(preferredMicID: micID) self.configureSession(preferredMicID: micID) + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let engine = AVAudioEngine() self.audioEngine = engine diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 5b866304b090..46e5d80a01eb 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -316,7 +316,12 @@ final class WebChatSwiftUIWindowController { let controller = NSViewController() let effectView = NSVisualEffectView() effectView.material = .sidebar - effectView.blendingMode = .behindWindow + effectView.blendingMode = switch presentation { + case .panel: + .withinWindow + case .window: + .behindWindow + } effectView.state = .active effectView.wantsLayer = true effectView.layer?.cornerCurve = .continuous @@ -328,6 +333,7 @@ final class WebChatSwiftUIWindowController { } effectView.layer?.cornerRadius = cornerRadius effectView.layer?.masksToBounds = true + effectView.layer?.backgroundColor = NSColor.clear.cgColor effectView.translatesAutoresizingMaskIntoConstraints = true effectView.autoresizingMask = [.width, .height] @@ -335,6 +341,9 @@ final class WebChatSwiftUIWindowController { hosting.view.translatesAutoresizingMaskIntoConstraints = false hosting.view.wantsLayer = true + hosting.view.layer?.cornerCurve = .continuous + hosting.view.layer?.cornerRadius = cornerRadius + hosting.view.layer?.masksToBounds = true hosting.view.layer?.backgroundColor = NSColor.clear.cgColor controller.addChild(hosting) diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 0989164a01e6..151b7fdda94c 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -15,7 +15,7 @@ struct ConnectOptions { var clientMode: String = "ui" var displayName: String? var role: String = "operator" - var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + var scopes: [String] = defaultOperatorConnectScopes var help: Bool = false static func parse(_ args: [String]) -> ConnectOptions { diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift new file mode 100644 index 000000000000..479c176d5d84 --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -0,0 +1,7 @@ +let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 0a73fc2108c2..f75ef05fdb2e 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -251,7 +251,7 @@ actor GatewayWizardClient { let clientMode = "ui" let role = "operator" // Explicit scopes; gateway no longer defaults empty scopes to admin. - let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + let scopes = defaultOperatorConnectScopes let client: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(clientId), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), @@ -280,33 +280,27 @@ actor GatewayWizardClient { let connectNonce = try await self.waitForConnectChallenge() let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) - let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", - identity.deviceId, - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - self.token ?? "", - ] - if let connectNonce { - payloadParts.append(connectNonce) - } - let payload = payloadParts.joined(separator: "|") + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: identity.deviceId, + clientId: clientId, + clientMode: clientMode, + role: role, + scopes: scopes, + signedAtMs: signedAtMs, + token: self.token, + nonce: connectNonce, + platform: platform, + deviceFamily: "Mac") if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } @@ -333,29 +327,24 @@ actor GatewayWizardClient { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { - while true { - let message = try await task.receive() - let frame = try await self.decodeFrame(message) - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String - { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { + while true { + let message = try await task.receive() + let frame = try await self.decodeFrame(message) + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { return nil } - throw error - } + } + }) } } diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 2f2dd7f6090f..a7aaa7d3914a 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let agentid: String? public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + agentid: String?, threadid: String?, sessionkey: String?, idempotencykey: String) @@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.agentid = agentid self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey @@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case agentid = "agentId" case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" @@ -527,6 +531,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +558,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +584,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +612,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2178,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2440,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2532,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2381,11 +2593,18 @@ public struct CronRunLogEntry: Codable, Sendable { public let status: AnyCodable? public let error: String? public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? public let sessionid: String? public let sessionkey: String? public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2394,11 +2613,18 @@ public struct CronRunLogEntry: Codable, Sendable { status: AnyCodable?, error: String?, summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, sessionid: String?, sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2406,11 +2632,18 @@ public struct CronRunLogEntry: Codable, Sendable { self.status = status self.error = error self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror self.sessionid = sessionid self.sessionkey = sessionkey self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2420,11 +2653,18 @@ public struct CronRunLogEntry: Codable, Sendable { case status case error case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" case sessionid = "sessionId" case sessionkey = "sessionKey" case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2569,38 +2809,62 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String + public let commandargv: [String]? + public let systemrunplanv2: [String: AnyCodable]? + public let env: [String: AnyCodable]? public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? public let agentid: AnyCodable? public let resolvedpath: AnyCodable? public let sessionkey: AnyCodable? + public let turnsourcechannel: AnyCodable? + public let turnsourceto: AnyCodable? + public let turnsourceaccountid: AnyCodable? + public let turnsourcethreadid: AnyCodable? public let timeoutms: Int? public let twophase: Bool? public init( id: String?, command: String, + commandargv: [String]?, + systemrunplanv2: [String: AnyCodable]?, + env: [String: AnyCodable]?, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, agentid: AnyCodable?, resolvedpath: AnyCodable?, sessionkey: AnyCodable?, + turnsourcechannel: AnyCodable?, + turnsourceto: AnyCodable?, + turnsourceaccountid: AnyCodable?, + turnsourcethreadid: AnyCodable?, timeoutms: Int?, twophase: Bool?) { self.id = id self.command = command + self.commandargv = commandargv + self.systemrunplanv2 = systemrunplanv2 + self.env = env self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask self.agentid = agentid self.resolvedpath = resolvedpath self.sessionkey = sessionkey + self.turnsourcechannel = turnsourcechannel + self.turnsourceto = turnsourceto + self.turnsourceaccountid = turnsourceaccountid + self.turnsourcethreadid = turnsourcethreadid self.timeoutms = timeoutms self.twophase = twophase } @@ -2608,13 +2872,21 @@ public struct ExecApprovalRequestParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case id case command + case commandargv = "commandArgv" + case systemrunplanv2 = "systemRunPlanV2" + case env case cwd + case nodeid = "nodeId" case host case security case ask case agentid = "agentId" case resolvedpath = "resolvedPath" case sessionkey = "sessionKey" + case turnsourcechannel = "turnSourceChannel" + case turnsourceto = "turnSourceTo" + case turnsourceaccountid = "turnSourceAccountId" + case turnsourcethreadid = "turnSourceThreadId" case timeoutms = "timeoutMs" case twophase = "twoPhase" } @@ -2728,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { public let publickey: String public let displayname: String? public let platform: String? + public let devicefamily: String? public let clientid: String? public let clientmode: String? public let role: String? @@ -2744,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { publickey: String, displayname: String?, platform: String?, + devicefamily: String?, clientid: String?, clientmode: String?, role: String?, @@ -2759,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { self.publickey = publickey self.displayname = displayname self.platform = platform + self.devicefamily = devicefamily self.clientid = clientid self.clientmode = clientmode self.role = role @@ -2776,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { case publickey = "publicKey" case displayname = "displayName" case platform + case devicefamily = "deviceFamily" case clientid = "clientId" case clientmode = "clientMode" case role diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift index 6d5e4a37efd0..8794a3f22fce 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -59,12 +59,7 @@ struct AgentWorkspaceTests { try "hello".write(to: marker, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .unsafe: - break - case .safe: - #expect(Bool(false), "Expected unsafe bootstrap safety result.") - } + #expect(result.unsafeReason != nil) } @Test @@ -77,12 +72,7 @@ struct AgentWorkspaceTests { try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .safe: - break - case .unsafe: - #expect(Bool(false), "Expected safe bootstrap safety result.") - } + #expect(result.unsafeReason == nil) } @Test diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift deleted file mode 100644 index 84c618339328..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct AnthropicAuthControlsSmokeTests { - @Test func anthropicAuthControlsBuildsBodyLocal() { - let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - let view = AnthropicAuthControls( - connectionMode: .local, - oauthStatus: .connected(expiresAtMs: 1_700_000_000_000), - pkce: pkce, - code: "code#state", - statusText: "Detected code", - autoDetectClipboard: false, - autoConnectClipboard: false) - _ = view.body - } - - @Test func anthropicAuthControlsBuildsBodyRemote() { - let view = AnthropicAuthControls( - connectionMode: .remote, - oauthStatus: .missingFile, - pkce: nil, - code: "", - statusText: nil) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift deleted file mode 100644 index c41b7f64be4c..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicAuthResolverTests { - @Test - func prefersOAuthFileOverEnv() throws { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - let oauthFile = dir.appendingPathComponent("oauth.json") - let payload = [ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ] - let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: oauthFile, options: [.atomic]) - - let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile) - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-ignored", - ], oauthStatus: status) - #expect(mode == .oauthFile) - } - - @Test - func reportsOAuthEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_OAUTH_TOKEN": "token", - ], oauthStatus: .missingFile) - #expect(mode == .oauthEnv) - } - - @Test - func reportsAPIKeyEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-key", - ], oauthStatus: .missingFile) - #expect(mode == .apiKeyEnv) - } - - @Test - func reportsMissingWhenNothingConfigured() { - let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile) - #expect(mode == .missing) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift deleted file mode 100644 index 3d337c2b279c..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicOAuthCodeStateTests { - @Test - func parsesRawToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesBacktickedToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesCallbackURL() { - let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876" - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func extractsFromSurroundingText() { - let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return." - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift new file mode 100644 index 000000000000..a175e5e1a0ac --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct AudioInputDeviceObserverTests { + @Test func hasUsableDefaultInputDeviceReturnsBool() { + // Smoke test: verifies the composition logic runs without crashing. + // Actual result depends on whether the host has an audio input device. + let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice() + _ = result // suppress unused-variable warning; the assertion is "no crash" + } + + @Test func hasUsableDefaultInputDeviceConsistentWithComponents() { + // When no default UID exists, the method must return false. + // When a default UID exists, the result must match alive-set membership. + let uid = AudioInputDeviceObserver.defaultInputDeviceUID() + let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let expected = uid.map { alive.contains($0) } ?? false + #expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 7a71bc08b6ea..d84706791838 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -66,6 +66,48 @@ import Testing } } + @Test func prefersOpenClawBinaryOverPnpm() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + let pnpmPath = binDir.appendingPathComponent("pnpm") + try self.makeExec(at: openclawPath) + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"])) + } + + @Test func usesOpenClawBinaryWithoutNodeRuntime() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "gateway", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) + } + @Test func fallsBackToPnpm() async throws { let defaults = self.makeDefaults() defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) @@ -76,7 +118,11 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.openclawCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) } @@ -95,7 +141,8 @@ import Testing subcommand: "health", extraArgs: ["--json", "--timeout", "5"], defaults: defaults, - configRoot: [:]) + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"])) #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 17f4a1e24ce6..3b27740d0664 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -16,14 +16,31 @@ struct ExecAllowlistTests { let cases: [Case] } + private struct WrapperResolutionParityFixture: Decodable { + struct Case: Decodable { + let id: String + let argv: [String] + let expectedRawExecutable: String? + } + + let cases: [Case] + } + private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] { - let fixtureURL = self.shellParserParityFixtureURL() + let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json") let data = try Data(contentsOf: fixtureURL) let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data) return fixture.cases } - private static func shellParserParityFixtureURL() -> URL { + private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] { + let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json") + let data = try Data(contentsOf: fixtureURL) + let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data) + return fixture.cases + } + + private static func fixtureURL(filename: String) -> URL { var repoRoot = URL(fileURLWithPath: #filePath) for _ in 0..<5 { repoRoot.deleteLastPathComponent() @@ -31,7 +48,7 @@ struct ExecAllowlistTests { return repoRoot .appendingPathComponent("test") .appendingPathComponent("fixtures") - .appendingPathComponent("exec-allowlist-shell-parser-parity.json") + .appendingPathComponent(filename) } @Test func matchUsesResolvedPath() { @@ -160,6 +177,17 @@ struct ExecAllowlistTests { } } + @Test func resolveMatchesSharedWrapperResolutionFixture() throws { + let fixtures = try Self.loadWrapperResolutionParityCases() + for fixture in fixtures { + let resolution = ExecCommandResolution.resolve( + command: fixture.argv, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolution?.rawExecutable == fixture.expectedRawExecutable) + } + } + @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { let command = ["/bin/sh", "./script.sh"] let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -171,6 +199,30 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "sh") } + @Test func resolveForAllowlistUnwrapsEnvShellWrapperChains() { + let command = ["/usr/bin/env", "/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].executableName == "touch") + } + + @Test func resolveForAllowlistUnwrapsEnvToEffectiveDirectExecutable() { + let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/printf") + #expect(resolutions[0].executableName == "printf") + } + @Test func matchAllRequiresEverySegmentToMatch() { let first = ExecCommandResolution( rawExecutable: "echo", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift new file mode 100644 index 000000000000..64ef6a21edad --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecHostRequestEvaluatorTests { + @Test func validateRequestRejectsEmptyCommand() { + let request = ExecHostRequest(command: [], rawCommand: nil, cwd: nil, env: nil, timeoutMs: nil, needsScreenRecording: nil, agentId: nil, sessionKey: nil, approvalDecision: nil) + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success: + Issue.record("expected invalid request") + case .failure(let error): + #expect(error.code == "INVALID_REQUEST") + #expect(error.message == "command required") + } + } + + @Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil) + switch decision { + case .requiresPrompt: + break + case .allow: + Issue.record("expected prompt requirement") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce) + switch decision { + case .allow(let approvedByAsk): + #expect(approvedByAsk) + case .requiresPrompt: + Issue.record("expected allow decision") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateDeniesOnExplicitDenyDecision() { + let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny) + switch decision { + case .deny(let error): + #expect(error.reason == "user-denied") + case .requiresPrompt: + Issue.record("expected deny decision") + case .allow: + Issue.record("expected deny decision") + } + } + + private static func makeContext( + security: ExecSecurity, + ask: ExecAsk, + allowlistSatisfied: Bool, + skillAllow: Bool) -> ExecApprovalEvaluation + { + ExecApprovalEvaluation( + command: ["/usr/bin/echo", "hi"], + displayCommand: "/usr/bin/echo hi", + agentId: nil, + security: security, + ask: ask, + env: [:], + resolution: nil, + allowlistResolutions: [], + allowlistMatches: [], + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: nil, + skillAllow: skillAllow) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift new file mode 100644 index 000000000000..ed3773a44ed6 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +private struct SystemRunCommandContractFixture: Decodable { + let cases: [SystemRunCommandContractCase] +} + +private struct SystemRunCommandContractCase: Decodable { + let name: String + let command: [String] + let rawCommand: String? + let expected: SystemRunCommandContractExpected +} + +private struct SystemRunCommandContractExpected: Decodable { + let valid: Bool + let displayCommand: String? + let errorContains: String? +} + +struct ExecSystemRunCommandValidatorTests { + @Test func matchesSharedSystemRunCommandContractFixture() throws { + for entry in try Self.loadContractCases() { + let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand) + + if !entry.expected.valid { + switch result { + case .ok(let resolved): + Issue.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)") + case .invalid(let message): + if let expected = entry.expected.errorContains { + #expect( + message.contains(expected), + "\(entry.name): expected error containing \(expected), got \(message)") + } + } + continue + } + + switch result { + case .ok(let resolved): + #expect( + resolved.displayCommand == entry.expected.displayCommand, + "\(entry.name): unexpected display command") + case .invalid(let message): + Issue.record("\(entry.name): unexpected invalid result: \(message)") + } + } + } + + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { + let fixtureURL = try self.findContractFixtureURL() + let data = try Data(contentsOf: fixtureURL) + let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data) + return decoded.cases + } + + private static func findContractFixtureURL() throws -> URL { + var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = cursor + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent("system-run-command-contract.json") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + cursor.deleteLastPathComponent() + } + throw NSError( + domain: "ExecSystemRunCommandValidatorTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"]) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift new file mode 100644 index 000000000000..7ee15107f40d --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import OpenClaw + +struct HostEnvSanitizerTests { + @Test func sanitizeBlocksShellTraceVariables() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "SHELLOPTS": "xtrace", + "PS4": "$(touch /tmp/pwned)", + "OPENCLAW_TEST": "1", + ]) + #expect(env["SHELLOPTS"] == nil) + #expect(env["PS4"] == nil) + #expect(env["OPENCLAW_TEST"] == "1") + } + + @Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() { + let env = HostEnvSanitizer.sanitize( + overrides: [ + "LANG": "C", + "LC_ALL": "C", + "OPENCLAW_TOKEN": "secret", + "PS4": "$(touch /tmp/pwned)", + ], + shellWrapper: true) + + #expect(env["LANG"] == "C") + #expect(env["LC_ALL"] == "C") + #expect(env["OPENCLAW_TOKEN"] == nil) + #expect(env["PS4"] == nil) + } + + @Test func sanitizeNonShellWrapperKeepsRegularOverrides() { + let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) + #expect(env["OPENCLAW_TOKEN"] == "secret") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 8395ed145ce8..ff63673b9e08 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } + + @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let summary = GatewayCostUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + days: 1, + daily: [ + GatewayCostUsageDay( + date: "2026-02-24", + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0), + ], + totals: GatewayCostUsageTotals( + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0)) + injector.setTestingCostUsageSummary(summary, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + + let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } + #expect(usageCostItem != nil) + #expect(usageCostItem?.submenu != nil) + #expect(usageCostItem?.submenu?.delegate == nil) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift deleted file mode 100644 index b34e9c3008ab..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct OpenClawOAuthStoreTests { - @Test - func returnsMissingWhenFileAbsent() { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)") - .appendingPathComponent("oauth.json") - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) - } - - @Test - func usesEnvOverrideForOpenClawOAuthDir() throws { - let key = "OPENCLAW_OAUTH_DIR" - let previous = ProcessInfo.processInfo.environment[key] - defer { - if let previous { - setenv(key, previous, 1) - } else { - unsetenv(key) - } - } - - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - setenv(key, dir.path, 1) - - #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) - } - - @Test - func acceptsPiFormatTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func acceptsTokenKeyVariants() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh_token": "r1", - "access_token": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func reportsMissingProviderEntry() throws { - let url = try self.writeOAuthFile([ - "other": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) - } - - @Test - func reportsMissingTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) - } - - private func writeOAuthFile(_ json: [String: Any]) throws -> URL { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - - let url = dir.appendingPathComponent("oauth.json") - let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - return url - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..5ee30af273d4 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -0,0 +1,36 @@ +import OpenClawProtocol +import Testing + +@testable import OpenClaw + +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: AnyCodable] = [ + "voiceId": AnyCodable("voice-legacy"), + "apiKey": AnyCodable("legacy-key"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") + #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift index 46971ac314c1..6640d526a741 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift @@ -17,6 +17,7 @@ import Testing #expect(opts.thinking == "low") #expect(opts.deliver == true) #expect(opts.to == nil) - #expect(opts.channel == .last) + #expect(opts.channel == .webchat) + #expect(opts.channel.shouldDeliver(opts.deliver) == false) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 145e17f3b7b6..627148381779 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -486,6 +486,10 @@ private final class ChatComposerNSTextView: NSTextView { override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 if isReturn { + if self.hasMarkedText() { + super.keyDown(with: event) + return + } if event.modifierFlags.contains(.shift) { super.insertNewline(nil) return diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift new file mode 100644 index 000000000000..858ef457c7e6 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthPayload.swift @@ -0,0 +1,55 @@ +import Foundation + +public enum GatewayDeviceAuthPayload { + public static func buildV3( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: [String], + signedAtMs: Int, + token: String?, + nonce: String, + platform: String?, + deviceFamily: String?) -> String + { + let scopeString = scopes.joined(separator: ",") + let authToken = token ?? "" + let normalizedPlatform = normalizeMetadataField(platform) + let normalizedDeviceFamily = normalizeMetadataField(deviceFamily) + return [ + "v3", + deviceId, + clientId, + clientMode, + role, + scopeString, + String(signedAtMs), + authToken, + nonce, + normalizedPlatform, + normalizedDeviceFamily, + ].joined(separator: "|") + } + + static func normalizeMetadataField(_ value: String?) -> String { + guard let value else { return "" } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "" + } + // Keep cross-runtime normalization deterministic (TS/Swift/Kotlin): + // lowercase ASCII A-Z only for auth payload metadata fields. + var output = String() + output.reserveCapacity(trimmed.count) + for scalar in trimmed.unicodeScalars { + let codePoint = scalar.value + if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) { + output.unicodeScalars.append(lowered) + } else { + output.unicodeScalars.append(scalar) + } + } + return output + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index f6aac26977a5..e8a53412cd10 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -127,6 +127,14 @@ private enum ConnectChallengeError: Error { case timeout } +private let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] + public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? @@ -146,8 +154,8 @@ public actor GatewayChannelActor { private var lastAuthSource: GatewayAuthSource = .none private let decoder = JSONDecoder() private let encoder = JSONEncoder() - // Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event, - // and we must include the nonce once the gateway requires v2 signing. + // Remote gateways (tailscale/wan) can take longer to deliver connect.challenge. + // Connect now requires this nonce before we send device-auth. private let connectTimeoutSeconds: Double = 12 private let connectChallengeTimeoutSeconds: Double = 6.0 // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, @@ -318,7 +326,7 @@ public actor GatewayChannelActor { let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let options = self.connectOptions ?? GatewayConnectOptions( role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: defaultOperatorConnectScopes, caps: [], commands: [], permissions: [:], @@ -390,33 +398,27 @@ public actor GatewayChannelActor { } let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() - let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", - identity?.deviceId ?? "", - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - authToken ?? "", - ] - if let connectNonce { - payloadParts.append(connectNonce) - } - let payload = payloadParts.joined(separator: "|") if includeDeviceIdentity, let identity { + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: identity.deviceId, + clientId: clientId, + clientMode: clientMode, + role: role, + scopes: scopes, + signedAtMs: signedAtMs, + token: authToken, + nonce: connectNonce, + platform: platform, + deviceFamily: InstanceIdentity.deviceFamily) if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } } @@ -545,33 +547,26 @@ public actor GatewayChannelActor { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { [weak self] in - guard let self else { return nil } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { throw ConnectChallengeError.timeout } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { - self.logger.warning("gateway connect challenge timed out") - return nil - } - throw error - } + } + }) } private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2f2dd7f6090f..a7aaa7d3914a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let agentid: String? public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + agentid: String?, threadid: String?, sessionkey: String?, idempotencykey: String) @@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.agentid = agentid self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey @@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case agentid = "agentId" case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" @@ -527,6 +531,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +558,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +584,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +612,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2178,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2440,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2532,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2381,11 +2593,18 @@ public struct CronRunLogEntry: Codable, Sendable { public let status: AnyCodable? public let error: String? public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? public let sessionid: String? public let sessionkey: String? public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2394,11 +2613,18 @@ public struct CronRunLogEntry: Codable, Sendable { status: AnyCodable?, error: String?, summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, sessionid: String?, sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2406,11 +2632,18 @@ public struct CronRunLogEntry: Codable, Sendable { self.status = status self.error = error self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror self.sessionid = sessionid self.sessionkey = sessionkey self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2420,11 +2653,18 @@ public struct CronRunLogEntry: Codable, Sendable { case status case error case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" case sessionid = "sessionId" case sessionkey = "sessionKey" case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2569,38 +2809,62 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String + public let commandargv: [String]? + public let systemrunplanv2: [String: AnyCodable]? + public let env: [String: AnyCodable]? public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? public let agentid: AnyCodable? public let resolvedpath: AnyCodable? public let sessionkey: AnyCodable? + public let turnsourcechannel: AnyCodable? + public let turnsourceto: AnyCodable? + public let turnsourceaccountid: AnyCodable? + public let turnsourcethreadid: AnyCodable? public let timeoutms: Int? public let twophase: Bool? public init( id: String?, command: String, + commandargv: [String]?, + systemrunplanv2: [String: AnyCodable]?, + env: [String: AnyCodable]?, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, agentid: AnyCodable?, resolvedpath: AnyCodable?, sessionkey: AnyCodable?, + turnsourcechannel: AnyCodable?, + turnsourceto: AnyCodable?, + turnsourceaccountid: AnyCodable?, + turnsourcethreadid: AnyCodable?, timeoutms: Int?, twophase: Bool?) { self.id = id self.command = command + self.commandargv = commandargv + self.systemrunplanv2 = systemrunplanv2 + self.env = env self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask self.agentid = agentid self.resolvedpath = resolvedpath self.sessionkey = sessionkey + self.turnsourcechannel = turnsourcechannel + self.turnsourceto = turnsourceto + self.turnsourceaccountid = turnsourceaccountid + self.turnsourcethreadid = turnsourcethreadid self.timeoutms = timeoutms self.twophase = twophase } @@ -2608,13 +2872,21 @@ public struct ExecApprovalRequestParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case id case command + case commandargv = "commandArgv" + case systemrunplanv2 = "systemRunPlanV2" + case env case cwd + case nodeid = "nodeId" case host case security case ask case agentid = "agentId" case resolvedpath = "resolvedPath" case sessionkey = "sessionKey" + case turnsourcechannel = "turnSourceChannel" + case turnsourceto = "turnSourceTo" + case turnsourceaccountid = "turnSourceAccountId" + case turnsourcethreadid = "turnSourceThreadId" case timeoutms = "timeoutMs" case twophase = "twoPhase" } @@ -2728,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { public let publickey: String public let displayname: String? public let platform: String? + public let devicefamily: String? public let clientid: String? public let clientmode: String? public let role: String? @@ -2744,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { publickey: String, displayname: String?, platform: String?, + devicefamily: String?, clientid: String?, clientmode: String?, role: String?, @@ -2759,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { self.publickey = publickey self.displayname = displayname self.platform = platform + self.devicefamily = devicefamily self.clientid = clientid self.clientmode = clientmode self.role = role @@ -2776,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { case publickey = "publicKey" case displayname = "displayName" case platform + case devicefamily = "deviceFamily" case clientid = "clientId" case clientmode = "clientMode" case role diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift new file mode 100644 index 000000000000..46a814f81a64 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceAuthPayloadTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import OpenClawKit + +@Suite("DeviceAuthPayload") +struct DeviceAuthPayloadTests { + @Test("builds canonical v3 payload vector") + func buildsCanonicalV3PayloadVector() { + let payload = GatewayDeviceAuthPayload.buildV3( + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: "tok-123", + nonce: "nonce-abc", + platform: " IOS ", + deviceFamily: " iPhone ") + #expect( + payload + == "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone") + } + + @Test("normalizes metadata with ASCII-only lowercase") + func normalizesMetadataWithAsciiLowercase() { + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos") + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac") + #expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "") + } +} diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js index a9cb659876a5..530287ca21db 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js @@ -32,6 +32,66 @@ if (modalElement && Array.isArray(modalElement.styles)) { modalElement.styles = [...modalElement.styles, modalStyles]; } +const appendComponentStyles = (tagName, extraStyles) => { + const component = customElements.get(tagName); + if (!component) { + return; + } + + const current = component.styles; + if (!current) { + component.styles = [extraStyles]; + return; + } + + component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles]; +}; + +appendComponentStyles( + "a2ui-row", + css` + @media (max-width: 860px) { + section { + flex-wrap: wrap; + align-content: flex-start; + } + + ::slotted(*) { + flex: 1 1 100%; + min-width: 100%; + width: 100%; + max-width: 100%; + } + } + `, +); + +appendComponentStyles( + "a2ui-column", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + +appendComponentStyles( + "a2ui-card", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + const emptyClasses = () => ({}); const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js new file mode 100644 index 000000000000..fe32d2c06165 --- /dev/null +++ b/assets/chrome-extension/background-utils.js @@ -0,0 +1,48 @@ +export function reconnectDelayMs( + attempt, + opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, +) { + const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; + const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; + const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; + const random = typeof opts.random === "function" ? opts.random : Math.random; + const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); + const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); + return backoff + Math.max(0, jitterMs) * random(); +} + +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`openclaw-extension-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { + const token = String(gatewayToken || "").trim(); + if (!token) { + throw new Error( + "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", + ); + } + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; +} + +export function isRetryableReconnectError(err) { + const message = err instanceof Error ? err.message : String(err || ""); + if (message.includes("Missing gatewayToken")) { + return false; + } + return true; +} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 7a1754e06c96..c78f2c7c4527 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,3 +1,5 @@ +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + const DEFAULT_PORT = 18792 const BADGE = { @@ -11,8 +13,9 @@ const BADGE = { let relayWs = null /** @type {Promise|null} */ let relayConnectPromise = null - -let debuggerListenersInstalled = false +let relayGatewayToken = '' +/** @type {string|null} */ +let relayConnectRequestId = null let nextSession = 1 @@ -26,6 +29,18 @@ const childSessionToTab = new Map() /** @type {Mapvoid, reject:(e:Error)=>void}>} */ const pending = new Map() +// Per-tab operation locks prevent double-attach races. +/** @type {Set} */ +const tabOperationLocks = new Set() + +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + +// Reconnect state for exponential backoff. +let reconnectAttempt = 0 +let reconnectTimer = null + function nowStack() { try { return new Error().stack || '' @@ -55,6 +70,63 @@ function setBadge(tabId, kind) { void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) } +// Persist attached tab state to survive MV3 service worker restarts. +async function persistState() { + try { + const tabEntries = [] + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected' && tab.sessionId && tab.targetId) { + tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) + } + } + await chrome.storage.session.set({ + persistedTabs: tabEntries, + nextSession, + }) + } catch { + // chrome.storage.session may not be available in all contexts. + } +} + +// Rehydrate tab state on service worker startup. Fast path — just restores +// maps and badges. Relay reconnect happens separately in background. +async function rehydrateState() { + try { + const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) + if (stored.nextSession) { + nextSession = Math.max(nextSession, stored.nextSession) + } + const entries = stored.persistedTabs || [] + // Phase 1: optimistically restore state and badges. + for (const entry of entries) { + tabs.set(entry.tabId, { + state: 'connected', + sessionId: entry.sessionId, + targetId: entry.targetId, + attachOrder: entry.attachOrder, + }) + tabBySession.set(entry.sessionId, entry.tabId) + setBadge(entry.tabId, 'on') + } + // Phase 2: validate asynchronously, remove dead tabs. + for (const entry of entries) { + try { + await chrome.tabs.get(entry.tabId) + await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(entry.tabId) + tabBySession.delete(entry.sessionId) + setBadge(entry.tabId, 'off') + } + } + } catch { + // Ignore rehydration errors. + } +} + async function ensureRelayConnection() { if (relayWs && relayWs.readyState === WebSocket.OPEN) return if (relayConnectPromise) return await relayConnectPromise @@ -63,9 +135,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = gatewayToken - ? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}` - : `ws://127.0.0.1:${port}/extension` + const wsUrl = await buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -74,14 +144,15 @@ async function ensureRelayConnection() { throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) } - if (!gatewayToken) { - throw new Error( - 'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)', - ) - } - const ws = new WebSocket(wsUrl) relayWs = ws + relayGatewayToken = gatewayToken + // Bind message handler before open so an immediate first frame (for example + // gateway connect.challenge) cannot be missed. + ws.onmessage = (event) => { + if (ws !== relayWs) return + void whenReady(() => onRelayMessage(String(event.data || ''))) + } await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) @@ -99,42 +170,142 @@ async function ensureRelayConnection() { } }) - ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) - ws.onclose = () => onRelayClosed('closed') - ws.onerror = () => onRelayClosed('error') - - if (!debuggerListenersInstalled) { - debuggerListenersInstalled = true - chrome.debugger.onEvent.addListener(onDebuggerEvent) - chrome.debugger.onDetach.addListener(onDebuggerDetach) + // Bind permanent handlers. Guard against stale socket: if this WS was + // replaced before its close fires, the handler is a no-op. + ws.onclose = () => { + if (ws !== relayWs) return + onRelayClosed('closed') + } + ws.onerror = () => { + if (ws !== relayWs) return + onRelayClosed('error') } })() try { await relayConnectPromise + reconnectAttempt = 0 } finally { relayConnectPromise = null } } +// Relay closed — update badges, reject pending requests, auto-reconnect. +// Debugger sessions are kept alive so they survive transient WS drops. function onRelayClosed(reason) { relayWs = null + relayGatewayToken = '' + relayConnectRequestId = null + for (const [id, p] of pending.entries()) { pending.delete(id) p.reject(new Error(`Relay disconnected (${reason})`)) } - for (const tabId of tabs.keys()) { - void chrome.debugger.detach({ tabId }).catch(() => {}) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: disconnected (click to re-attach)', - }) + reattachPending.clear() + + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay reconnecting…', + }) + } } - tabs.clear() - tabBySession.clear() - childSessionToTab.clear() + + scheduleReconnect() +} + +function scheduleReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const delay = reconnectDelayMs(reconnectAttempt) + reconnectAttempt++ + + console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) + + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + try { + await ensureRelayConnection() + reconnectAttempt = 0 + console.log('Reconnected successfully') + await reannounceAttachedTabs() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) + if (!isRetryableReconnectError(err)) { + return + } + scheduleReconnect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempt = 0 +} + +// Re-announce all attached tabs to the relay after reconnect. +async function reannounceAttachedTabs() { + for (const [tabId, tab] of tabs.entries()) { + if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue + + // Verify debugger is still attached. + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(tabId) + if (tab.sessionId) tabBySession.delete(tab.sessionId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + continue + } + + // Send fresh attach event to relay. + try { + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') + ) + const targetInfo = info?.targetInfo + + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: tab.sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + setBadge(tabId, 'on') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: attached (click to detach)', + }) + } catch { + setBadge(tabId, 'on') + } + } + + await persistState() } function sendToRelay(payload) { @@ -145,6 +316,33 @@ function sendToRelay(payload) { ws.send(JSON.stringify(payload)) } +function ensureGatewayHandshakeStarted(payload) { + if (relayConnectRequestId) return + const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : '' + relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` + sendToRelay({ + type: 'req', + id: relayConnectRequestId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'chrome-relay-extension', + version: '1.0.0', + platform: 'chrome-extension', + mode: 'webchat', + }, + role: 'operator', + scopes: ['operator.read', 'operator.write'], + caps: [], + commands: [], + nonce: nonce || undefined, + auth: relayGatewayToken ? { token: relayGatewayToken } : undefined, + }, + }) +} + async function maybeOpenHelpOnce() { try { const stored = await chrome.storage.local.get(['helpOnErrorShown']) @@ -159,10 +357,18 @@ async function maybeOpenHelpOnce() { function requestFromRelay(command) { const id = command.id return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('Relay request timeout (30s)')) + }, 30000) + pending.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v) }, + reject: (e) => { clearTimeout(timer); reject(e) }, + }) try { sendToRelay(command) } catch (err) { + clearTimeout(timer) pending.delete(id) reject(err instanceof Error ? err : new Error(String(err))) } @@ -178,6 +384,33 @@ async function onRelayMessage(text) { return } + if (msg && msg.type === 'event' && msg.event === 'connect.challenge') { + try { + ensureGatewayHandshakeStarted(msg.payload) + } catch (err) { + console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err)) + relayConnectRequestId = null + const ws = relayWs + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1008, 'gateway connect failed') + } + } + return + } + + if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) { + relayConnectRequestId = null + if (!msg.ok) { + const detail = msg?.error?.message || msg?.error || 'gateway connect failed' + console.warn('gateway connect handshake rejected', String(detail)) + const ws = relayWs + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1008, 'gateway connect failed') + } + } + return + } + if (msg && msg.method === 'ping') { try { sendToRelay({ method: 'pong' }) @@ -233,8 +466,9 @@ async function attachTab(tabId, opts = {}) { throw new Error('Target.getTargetInfo returned no targetId') } - const sessionId = `cb-tab-${nextSession++}` - const attachOrder = nextSession + const sid = nextSession++ + const sessionId = `cb-tab-${sid}` + const attachOrder = sid tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) tabBySession.set(sessionId, tabId) @@ -258,11 +492,33 @@ async function attachTab(tabId, opts = {}) { } setBadge(tabId, 'on') + await persistState() + return { sessionId, targetId } } async function detachTab(tabId, reason) { const tab = tabs.get(tabId) + + // Send detach events for child sessions first. + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: childSessionId, reason: 'parent_detached' }, + }, + }) + } catch { + // Relay may be down. + } + childSessionToTab.delete(childSessionId) + } + } + + // Send detach event for main session. if (tab?.sessionId && tab?.targetId) { try { sendToRelay({ @@ -273,21 +529,17 @@ async function detachTab(tabId, reason) { }, }) } catch { - // ignore + // Relay may be down. } } if (tab?.sessionId) tabBySession.delete(tab.sessionId) tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - try { await chrome.debugger.detach({ tabId }) } catch { - // ignore + // May already be detached. } setBadge(tabId, 'off') @@ -295,6 +547,8 @@ async function detachTab(tabId, reason) { tabId, title: 'OpenClaw Browser Relay (click to attach/detach)', }) + + await persistState() } async function connectOrToggleForActiveTab() { @@ -302,33 +556,53 @@ async function connectOrToggleForActiveTab() { const tabId = active?.id if (!tabId) return - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) + // Prevent concurrent operations on the same tab. + if (tabOperationLocks.has(tabId)) return + tabOperationLocks.add(tabId) try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + // User is manually connecting — cancel any pending reconnect. + cancelReconnect() + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') void chrome.action.setTitle({ tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + title: 'OpenClaw Browser Relay: connecting to local relay…', }) - void maybeOpenHelpOnce() - // Extra breadcrumbs in chrome://extensions service worker logs. - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + }) + void maybeOpenHelpOnce() + const message = err instanceof Error ? err.message : String(err) + console.warn('attach failed', message, nowStack()) + } + } finally { + tabOperationLocks.delete(tabId) } } @@ -337,14 +611,12 @@ async function handleForwardCdpCommand(msg) { const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - // Map command to tab const bySession = sessionId ? getTabBySessionId(sessionId) : null const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined const tabId = bySession?.tabId || (targetId ? getTabByTargetId(targetId) : null) || (() => { - // No sessionId: pick the first connected tab (stable-ish). for (const [id, tab] of tabs.entries()) { if (tab.state === 'connected') return id } @@ -434,20 +706,256 @@ function onDebuggerEvent(source, method, params) { }, }) } catch { - // ignore + // Relay may be down. } } -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - void detachTab(tabId, reason) + + // User explicitly cancelled or DevTools replaced the connection — respect their intent + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } + + // Check if tab still exists — distinguishes navigation from tab close + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + // Tab is gone (closed) — normal cleanup + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } -chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) +// Tab lifecycle listeners — clean up stale entries. +chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) + if (!tabs.has(tabId)) return + const tab = tabs.get(tabId) + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, + }, + }) + } catch { + // Relay may be down. + } + } + void persistState() +})) + +chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { + const tab = tabs.get(removedTabId) + if (!tab) return + tabs.delete(removedTabId) + tabs.set(addedTabId, tab) + if (tab.sessionId) { + tabBySession.set(tab.sessionId, addedTabId) + } + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === removedTabId) { + childSessionToTab.set(childSessionId, addedTabId) + } + } + setBadge(addedTabId, 'on') + void persistState() +})) + +// Register debugger listeners at module scope so detach/event handling works +// even when the relay WebSocket is down. +chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) +chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) + +chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) + +// Refresh badge after navigation completes — service worker may have restarted +// during navigation, losing ephemeral badge state. +chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { + if (frameId !== 0) return + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +// Refresh badge when user switches to an attached tab. +chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) chrome.runtime.onInstalled.addListener(() => { - // Useful: first-time instructions. void chrome.runtime.openOptionsPage() }) + +// MV3 keepalive via chrome.alarms — more reliable than setInterval across +// service worker restarts. Checks relay health and refreshes badges. +chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== 'relay-keepalive') return + await initPromise + + if (tabs.size === 0) return + + // Refresh badges (ephemeral in MV3). + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } + } + + // If relay is down and no reconnect is in progress, trigger one. + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + if (!relayConnectPromise && !reconnectTimer) { + console.log('Keepalive: WebSocket unhealthy, triggering reconnect') + await ensureRelayConnection().catch(() => { + // ensureRelayConnection may throw without triggering onRelayClosed + // (e.g. preflight fetch fails before WS is created), so ensure + // reconnect is always scheduled on failure. + if (!reconnectTimer) { + scheduleReconnect() + } + }) + } + } +}) + +// Rehydrate state on service worker startup. Split: rehydration is the gate +// (fast), relay reconnect runs in background (slow, non-blocking). +const initPromise = rehydrateState() + +initPromise.then(() => { + if (tabs.size > 0) { + ensureRelayConnection().then(() => { + reconnectAttempt = 0 + return reannounceAttachedTabs() + }).catch(() => { + scheduleReconnect() + }) + } +}) + +// Shared gate: all state-dependent handlers await this before accessing maps. +async function whenReady(fn) { + await initPromise + return fn() +} + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json index d6b593990de2..62038276cd75 100644 --- a/assets/chrome-extension/manifest.json +++ b/assets/chrome-extension/manifest.json @@ -9,7 +9,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["debugger", "tabs", "activeTab", "storage"], + "permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "background": { "service_worker": "background.js", "type": "module" }, "action": { diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 000000000000..53e2cd550147 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index e4252ccae4c2..aa6fcc4901fd 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,12 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -function relayHeaders(token) { - const t = String(token || '').trim() - if (!t) return {} - return { 'x-openclaw-relay-token': t } -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -33,27 +30,21 @@ async function checkRelayReachable(port, token) { setStatus('error', 'Gateway token required. Save your gateway token to connect.') return } - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { - method: 'GET', - headers: relayHeaders(trimmedToken), - signal: ctrl.signal, + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, }) - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } finally { - clearTimeout(t) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/changelog/fragments/README.md b/changelog/fragments/README.md new file mode 100644 index 000000000000..93bb5b65d706 --- /dev/null +++ b/changelog/fragments/README.md @@ -0,0 +1,13 @@ +# Changelog Fragments + +Use this directory when a PR should not edit `CHANGELOG.md` directly. + +- One fragment file per PR. +- File name recommendation: `pr-.md`. +- Include at least one line with both `#` and `thanks @`. + +Example: + +```md +- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice) +``` diff --git a/docker-setup.sh b/docker-setup.sh index 00c3cf1924fd..1f6e51cd75d8 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -20,6 +20,78 @@ require_cmd() { fi } +read_config_gateway_token() { + local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json" + if [[ ! -f "$config_path" ]]; then + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$config_path" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) +except Exception: + raise SystemExit(0) + +gateway = cfg.get("gateway") +if not isinstance(gateway, dict): + raise SystemExit(0) +auth = gateway.get("auth") +if not isinstance(auth, dict): + raise SystemExit(0) +token = auth.get("token") +if isinstance(token, str): + token = token.strip() + if token: + print(token) +PY + return 0 + fi + if command -v node >/dev/null 2>&1; then + node - "$config_path" <<'NODE' +const fs = require("node:fs"); +const configPath = process.argv[2]; +try { + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + const token = cfg?.gateway?.auth?.token; + if (typeof token === "string" && token.trim().length > 0) { + process.stdout.write(token.trim()); + } +} catch { + // Keep docker-setup resilient when config parsing fails. +} +NODE + fi +} + +ensure_control_ui_allowed_origins() { + if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then + return 0 + fi + + local allowed_origin_json + local current_allowed_origins + allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")" + current_allowed_origins="$( + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config get gateway.controlUi.allowedOrigins 2>/dev/null || true + )" + current_allowed_origins="${current_allowed_origins//$'\r'/}" + + if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then + echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged." + return 0 + fi + + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null + echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." +} + contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] @@ -82,6 +154,9 @@ fi mkdir -p "$OPENCLAW_CONFIG_DIR" mkdir -p "$OPENCLAW_WORKSPACE_DIR" +# Seed device-identity parent eagerly for Docker Desktop/Windows bind mounts +# that reject creating new subdirectories from inside the container. +mkdir -p "$OPENCLAW_CONFIG_DIR/identity" export OPENCLAW_CONFIG_DIR export OPENCLAW_WORKSPACE_DIR @@ -94,7 +169,11 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - if command -v openssl >/dev/null 2>&1; then + EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)" + if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then + OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN" + echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json" + elif command -v openssl >/dev/null 2>&1; then OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" else OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' @@ -244,12 +323,20 @@ upsert_env "$ENV_FILE" \ OPENCLAW_HOME_VOLUME \ OPENCLAW_DOCKER_APT_PACKAGES -echo "==> Building Docker image: $IMAGE_NAME" -docker build \ - --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ - -t "$IMAGE_NAME" \ - -f "$ROOT_DIR/Dockerfile" \ - "$ROOT_DIR" +if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then + echo "==> Building Docker image: $IMAGE_NAME" + docker build \ + --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/Dockerfile" \ + "$ROOT_DIR" +else + echo "==> Pulling Docker image: $IMAGE_NAME" + if ! docker pull "$IMAGE_NAME"; then + echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2 + exit 1 + fi +fi echo "" echo "==> Onboarding (interactive)" @@ -262,6 +349,10 @@ echo " - Install Gateway daemon: No" echo "" docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon +echo "" +echo "==> Control UI origin allowlist" +ensure_control_ui_allowed_origins + echo "" echo "==> Provider setup (optional)" echo "WhatsApp (QR):" diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index aae5f58fdf21..8d1401926079 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -349,7 +349,8 @@ Notes: ## Storage & history - Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). -- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned). +- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned by size and line count). +- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable). - Override store path: `cron.store` in config. ## Configuration @@ -362,10 +363,21 @@ Notes: maxConcurrentRuns: 1, // default 1 webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode + sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` +Run-log pruning behavior: + +- `cron.runLog.maxBytes`: max run-log file size before pruning. +- `cron.runLog.keepLines`: when pruning, keep only the newest N lines. +- Both apply to `cron/runs/.jsonl` files. + Webhook behavior: - Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. @@ -380,6 +392,85 @@ Disable cron entirely: - `cron.enabled: false` (config) - `OPENCLAW_SKIP_CRON=1` (env) +## Maintenance + +Cron has two built-in maintenance paths: isolated run-session retention and run-log pruning. + +### Defaults + +- `cron.sessionRetention`: `24h` (set `false` to disable run-session pruning) +- `cron.runLog.maxBytes`: `2_000_000` bytes +- `cron.runLog.keepLines`: `2000` + +### How it works + +- Isolated runs create session entries (`...:cron::run:`) and transcript files. +- The reaper removes expired run-session entries older than `cron.sessionRetention`. +- For removed run sessions no longer referenced by the session store, OpenClaw archives transcript files and purges old deleted archives on the same retention window. +- After each run append, `cron/runs/.jsonl` is size-checked: + - if file size exceeds `runLog.maxBytes`, it is trimmed to the newest `runLog.keepLines` lines. + +### Performance caveat for high volume schedulers + +High-frequency cron setups can generate large run-session and run-log footprints. Maintenance is built in, but loose limits can still create avoidable IO and cleanup work. + +What to watch: + +- long `cron.sessionRetention` windows with many isolated runs +- high `cron.runLog.keepLines` combined with large `runLog.maxBytes` +- many noisy recurring jobs writing to the same `cron/runs/.jsonl` + +What to do: + +- keep `cron.sessionRetention` as short as your debugging/audit needs allow +- keep run logs bounded with moderate `runLog.maxBytes` and `runLog.keepLines` +- move noisy background jobs to isolated mode with delivery rules that avoid unnecessary chatter +- review growth periodically with `openclaw cron runs` and adjust retention before logs become large + +### Customize examples + +Keep run sessions for a week and allow bigger run logs: + +```json5 +{ + cron: { + sessionRetention: "7d", + runLog: { + maxBytes: "10mb", + keepLines: 5000, + }, + }, +} +``` + +Disable isolated run-session pruning but keep run-log pruning: + +```json5 +{ + cron: { + sessionRetention: false, + runLog: { + maxBytes: "5mb", + keepLines: 3000, + }, + }, +} +``` + +Tune for high-volume cron usage (example): + +```json5 +{ + cron: { + sessionRetention: "12h", + runLog: { + maxBytes: "3mb", + keepLines: 1500, + }, + }, +} +``` + ## CLI quickstart One-shot reminder (UTC ISO, auto-delete after success): diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index c25cbcb80dbc..9676d960d236 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -62,7 +62,7 @@ The agent reads this on each heartbeat and handles all items in one turn. defaults: { heartbeat: { every: "30m", // interval - target: "last", // where to deliver alerts + target: "last", // explicit alert delivery target (default is "none") activeHours: { start: "08:00", end: "22:00" }, // optional }, }, diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 66b96cd1e9e9..0f561741d9a8 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -182,9 +182,7 @@ The `metadata.openclaw` object supports: The `handler.ts` file exports a `HookHandler` function: ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const myHandler: HookHandler = async (event) => { +const myHandler = async (event) => { // Only trigger on 'new' command if (event.type !== "command" || event.action !== "new") { return; @@ -305,13 +303,15 @@ Message events include rich context about the message: #### Example: Message Logger Hook ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; -import { isMessageReceivedEvent, isMessageSentEvent } from "../../src/hooks/internal-hooks.js"; +const isMessageReceivedEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "received"; +const isMessageSentEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "sent"; -const handler: HookHandler = async (event) => { - if (isMessageReceivedEvent(event)) { +const handler = async (event) => { + if (isMessageReceivedEvent(event as { type: string; action: string })) { console.log(`[message-logger] Received from ${event.context.from}: ${event.context.content}`); - } else if (isMessageSentEvent(event)) { + } else if (isMessageSentEvent(event as { type: string; action: string })) { console.log(`[message-logger] Sent to ${event.context.to}: ${event.context.content}`); } }; @@ -364,9 +364,7 @@ This hook does something useful when you issue `/new`. ### 4. Create handler.ts ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const handler: HookHandler = async (event) => { +const handler = async (event) => { if (event.type !== "command" || event.action !== "new") { return; } @@ -793,13 +791,17 @@ Test your handlers in isolation: ```typescript import { test } from "vitest"; -import { createHookEvent } from "./src/hooks/hooks.js"; import myHandler from "./hooks/my-hook/handler.js"; test("my handler works", async () => { - const event = createHookEvent("command", "new", "test-session", { - foo: "bar", - }); + const event = { + type: "command", + action: "new", + sessionKey: "test-session", + timestamp: new Date(), + messages: [], + context: { foo: "bar" }, + }; await myHandler(event); diff --git a/docs/channels/discord.md b/docs/channels/discord.md index d725b5c2edd6..58483ef22b65 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -376,6 +376,12 @@ Example: If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode). + Multi-account precedence: + + - `channels.discord.accounts.default.allowFrom` applies only to the `default` account. + - Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` is unset. + - Named accounts do not inherit `channels.discord.accounts.default.allowFrom`. + DM target format for delivery: - `user:` @@ -397,7 +403,8 @@ Example: `allowlist` behavior: - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - - optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - if a guild has `channels` configured, non-listed channels are denied - if a guild has no `channels` block, all channels in that allowlisted guild are allowed @@ -425,7 +432,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. @@ -664,9 +671,10 @@ Default slash command settings: - `session.threadBindings.*` sets global defaults. - `channels.discord.threadBindings.*` overrides Discord behavior. - `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`. + - `spawnAcpSessions` must be true to auto-create/bind threads for ACP (`/acp spawn ... --thread ...` or `sessions_spawn({ runtime: "acp", thread: true })`). - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. - See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference). + See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference). @@ -768,7 +776,7 @@ Default slash command settings: Notes: - allowlists can use `pk:` - - member display names are matched by name/slug + - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true` - lookups use original message ID and are time-window constrained - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` @@ -918,6 +926,8 @@ Auto-join example: channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -932,6 +942,10 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. +- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. +- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. +- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). ## Voice messages @@ -1007,6 +1021,18 @@ openclaw logs --follow If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. + + + + - keep OpenClaw current (`openclaw update`) so the Discord voice receive recovery logic is present + - confirm `channels.discord.voice.daveEncryption=true` (default) + - start from `channels.discord.voice.decryptionFailureTolerance=24` (upstream default) and tune only if needed + - watch logs for: + - `discord voice: DAVE decrypt failures detected` + - `discord voice: repeated decrypt failures; attempting rejoin` + - if failures continue after automatic rejoin, collect logs and compare against [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419) + + ## Configuration reference pointers diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 818a8288f5df..8281d0fb0d24 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path: Use these identifiers for delivery and allowlists: -- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal). +- Direct messages: `users/` (recommended). +- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`. - Deprecated: `users/` is treated as a user id, not an email allowlist. - Spaces: `spaces/`. @@ -165,13 +166,14 @@ Use these identifiers for delivery and allowlists: googlechat: { enabled: true, serviceAccountFile: "/path/to/service-account.json", + // or serviceAccountRef: { source: "file", provider: "filemain", id: "/channels/googlechat/serviceAccount" } audienceType: "app-url", audience: "https://gateway.example.com/googlechat", webhookPath: "/googlechat", botUser: "users/1234567890", // optional; helps mention detection dm: { policy: "pairing", - allowFrom: ["users/1234567890", "name@example.com"], + allowFrom: ["users/1234567890"], }, groupPolicy: "allowlist", groups: { @@ -193,11 +195,15 @@ Use these identifiers for delivery and allowlists: Notes: - Service account credentials can also be passed inline with `serviceAccount` (JSON string). +- `serviceAccountRef` is also supported (env/file SecretRef), including per-account refs under `channels.googlechat.accounts..serviceAccountRef`. - Default webhook path is `/googlechat` if `webhookPath` isn’t set. +- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). +Secrets reference details: [Secrets Management](/gateway/secrets). + ## Troubleshooting ### 405 Method Not Allowed diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 6bd278846c5b..3f9df076454d 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -1,5 +1,5 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams/Zalo)" read_when: - Changing group chat behavior or mention gating title: "Groups" @@ -7,7 +7,7 @@ title: "Groups" # Groups -OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. +OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams, Zalo. ## Beginner intro (2 minutes) @@ -183,13 +183,15 @@ Control how group/room messages are handled per channel: Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists. - Discord: allowlist uses `channels.discord.guilds..channels`. - Slack: allowlist uses `channels.slack.channels`. - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. Quick mental model (evaluation order for group messages): @@ -253,7 +255,10 @@ Notes: Some channel configs support restricting which tools are available **inside a specific group/room/channel**. - `tools`: allow/deny tools for the whole group. -- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard. +- `toolsBySender`: per-sender overrides within the group. + Use explicit key prefixes: + `id:`, `e164:`, `username:`, `name:`, and `"*"` wildcard. + Legacy unprefixed keys are still accepted and matched as `id:` only. Resolution order (most specific wins): @@ -273,7 +278,7 @@ Example (Telegram): "-1001234567890": { tools: { deny: ["exec", "read", "write"] }, toolsBySender: { - "123456789": { alsoAllow: ["exec"] }, + "id:123456789": { alsoAllow: ["exec"] }, }, }, }, diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d7a1b6335977..5720da1714af 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -158,6 +158,7 @@ imsg send "test" Group sender allowlist: `channels.imessage.groupAllowFrom`. Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Mention gating for groups: diff --git a/docs/channels/index.md b/docs/channels/index.md index 181b8d080aad..f5ae87618528 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -25,6 +25,7 @@ Text is supported everywhere; media and reactions vary by channel. - [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). +- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately). - [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). diff --git a/docs/channels/irc.md b/docs/channels/irc.md index 2bf6fb4eb4f7..00403b6f92d7 100644 --- a/docs/channels/irc.md +++ b/docs/channels/irc.md @@ -1,6 +1,10 @@ --- title: IRC description: Connect OpenClaw to IRC channels and direct messages. +summary: "IRC plugin setup, access controls, and troubleshooting" +read_when: + - You want to connect OpenClaw to IRC channels or DMs + - You are configuring IRC allowlists, group policy, or mention gating --- Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages. @@ -53,7 +57,8 @@ Config keys: - Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` - `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) -Allowlist entries can use nick or `nick!user@host` forms. +Allowlist entries should use stable sender identities (`nick!user@host`). +Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`. ### Common gotcha: `allowFrom` is for DMs, not channels @@ -159,7 +164,7 @@ Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your "*": { deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], }, - eigen: { + "id:eigen": { deny: ["gateway", "nodes", "cron"], }, }, @@ -172,7 +177,9 @@ Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your Notes: -- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching. +- `toolsBySender` keys should use `id:` for IRC sender identity values: + `id:eigen` or `id:eigen!~eigen@174.127.248.171` for stronger matching. +- Legacy unprefixed keys are still accepted and matched as `id:` only. - The first matching sender policy wins; `"*"` is the wildcard fallback. For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups). diff --git a/docs/channels/line.md b/docs/channels/line.md index d32e683fbeb5..b87cbd3f5fbf 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -118,6 +118,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 04205d949711..9bb56d1ddb7b 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -195,6 +195,7 @@ Notes: ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). - Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index fa0d9393e0f7..702f72cc01f5 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -101,8 +101,10 @@ Notes: ## Channels (groups) - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). -- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## Targets for outbound delivery diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 2232582610a5..9c4a583e1b54 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -87,7 +87,9 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. +- `channels.msteams.allowFrom` should use stable AAD object IDs. +- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`. +- The wizard can resolve names to IDs via Microsoft Graph when credentials allow. **Group access** @@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) -- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). @@ -469,6 +472,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.teams..channels..requireMention`: per-channel override. - `channels.msteams.teams..channels..tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). - `channels.msteams.teams..channels..toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported). +- `toolsBySender` keys should use explicit prefixes: + `id:`, `e164:`, `username:`, `name:` (legacy unprefixed keys still map to `id:` only). - `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). ## Routing & Sessions diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 4b575eb87c76..d402de166623 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl Stored under `~/.openclaw/credentials/`: - Pending requests: `-pairing.json` -- Approved allowlist store: `-allowFrom.json` +- Approved allowlist store: + - Default account: `-allowFrom.json` + - Non-default account: `--allowFrom.json` + +Account scoping behavior: + +- Non-default accounts read/write only their scoped allowlist file. +- Default account uses the channel-scoped unscoped allowlist file. Treat these as sensitive (they gate access to your assistant). diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 60bb5f7ce92c..b216af120ce0 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,7 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 0d0bba3cb270..22b7f816e376 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -152,6 +152,12 @@ For actions/directory reads, user token can be preferred when configured. For wr - `dm.groupEnabled` (group DMs default false) - `dm.groupChannels` (optional MPIM allowlist) + Multi-account precedence: + + - `channels.slack.accounts.default.allowFrom` applies only to the `default` account. + - Named accounts inherit `channels.slack.allowFrom` when their own `allowFrom` is unset. + - Named accounts do not inherit `channels.slack.accounts.default.allowFrom`. + Pairing in DMs uses `openclaw pairing approve slack `. @@ -165,12 +171,13 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - unresolved entries are kept as configured + - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -191,6 +198,8 @@ For actions/directory reads, user token can be preferred when configured. For wr - `skills` - `systemPrompt` - `tools`, `toolsBySender` + - `toolsBySender` key format: `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard + (legacy unprefixed keys still map to `id:` only) @@ -241,7 +250,7 @@ Manual reply tags are supported: - `[[reply_to_current]]` - `[[reply_to:]]` -Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. +Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. The difference reflects the platform threading models: Slack threads hide messages from the channel, while Telegram replies remain visible in the main chat flow. ## Media, chunking, and delivery @@ -511,6 +520,7 @@ Primary reference: High-signal Slack fields: - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` + - compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed) - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md new file mode 100644 index 000000000000..89e96b318a3b --- /dev/null +++ b/docs/channels/synology-chat.md @@ -0,0 +1,128 @@ +--- +summary: "Synology Chat webhook setup and OpenClaw config" +read_when: + - Setting up Synology Chat with OpenClaw + - Debugging Synology Chat webhook routing +title: "Synology Chat" +--- + +# Synology Chat (plugin) + +Status: supported via plugin as a direct-message channel using Synology Chat webhooks. +The plugin accepts inbound messages from Synology Chat outgoing webhooks and sends replies +through a Synology Chat incoming webhook. + +## Plugin required + +Synology Chat is plugin-based and not part of the default core channel install. + +Install from a local checkout: + +```bash +openclaw plugins install ./extensions/synology-chat +``` + +Details: [Plugins](/tools/plugin) + +## Quick setup + +1. Install and enable the Synology Chat plugin. +2. In Synology Chat integrations: + - Create an incoming webhook and copy its URL. + - Create an outgoing webhook with your secret token. +3. Point the outgoing webhook URL to your OpenClaw gateway: + - `https://gateway-host/webhook/synology` by default. + - Or your custom `channels.synology-chat.webhookPath`. +4. Configure `channels.synology-chat` in OpenClaw. +5. Restart gateway and send a DM to the Synology Chat bot. + +Minimal config: + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + token: "synology-outgoing-token", + incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123456"], + rateLimitPerMinute: 30, + allowInsecureSsl: false, + }, + }, +} +``` + +## Environment variables + +For the default account, you can use env vars: + +- `SYNOLOGY_CHAT_TOKEN` +- `SYNOLOGY_CHAT_INCOMING_URL` +- `SYNOLOGY_NAS_HOST` +- `SYNOLOGY_ALLOWED_USER_IDS` (comma-separated) +- `SYNOLOGY_RATE_LIMIT` +- `OPENCLAW_BOT_NAME` + +Config values override env vars. + +## DM policy and access control + +- `dmPolicy: "allowlist"` is the recommended default. +- `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. +- In `allowlist` mode, an empty `allowedUserIds` list is treated as misconfiguration and the webhook route will not start (use `dmPolicy: "open"` for allow-all). +- `dmPolicy: "open"` allows any sender. +- `dmPolicy: "disabled"` blocks DMs. +- Pairing approvals work with: + - `openclaw pairing list synology-chat` + - `openclaw pairing approve synology-chat ` + +## Outbound delivery + +Use numeric Synology Chat user IDs as targets. + +Examples: + +```bash +openclaw message send --channel synology-chat --target 123456 --text "Hello from OpenClaw" +openclaw message send --channel synology-chat --target synology-chat:123456 --text "Hello again" +``` + +Media sends are supported by URL-based file delivery. + +## Multi-account + +Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`. +Each account can override token, incoming URL, webhook path, DM policy, and limits. + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + accounts: { + default: { + token: "token-a", + incomingUrl: "https://nas-a.example.com/...token=...", + }, + alerts: { + token: "token-b", + incomingUrl: "https://nas-b.example.com/...token=...", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["987654"], + }, + }, + }, + }, +} +``` + +## Security notes + +- Keep `token` secret and rotate it if leaked. +- Keep `allowInsecureSsl: false` unless you explicitly trust a self-signed local NAS cert. +- Inbound webhook requests are token-verified and rate-limited per sender. +- Prefer `dmPolicy: "allowlist"` for production. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 8676bce4e97c..a4713d9c027f 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -47,6 +47,7 @@ Status: production-ready for bot DMs + groups via grammY. Long polling is the de ``` Env fallback: `TELEGRAM_BOT_TOKEN=...` (default account only). + Telegram does **not** use `openclaw channels login telegram`; configure token in config/env, then start gateway. @@ -148,6 +149,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). Example: allow any member in one specific group: @@ -551,6 +553,7 @@ curl "https://api.telegram.org/bot/getUpdates" Notes: - `own` means user reactions to bot-sent messages only (best-effort via sent-message cache). + - Reaction events still respect Telegram access controls (`dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`); unauthorized senders are dropped. - Telegram does not provide thread IDs in reaction updates. - non-forum groups route to group chat session - forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic @@ -670,6 +673,29 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - Node 22+ defaults to `autoSelectFamily=true` (except WSL2) and `dnsResultOrder=ipv4first`. + - If your host is WSL2 or explicitly works better with IPv4-only behavior, force family selection: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment overrides (temporary): + - `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER=ipv4first` - Validate DNS answers: ```bash @@ -693,6 +719,10 @@ Primary reference: - `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. +- Multi-account precedence: + - `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account. + - Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset. + - Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`. - `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults). - `channels.telegram.groups..groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`). - `channels.telegram.groups..requireMention`: mention gating default. @@ -712,7 +742,8 @@ Primary reference: - `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). -- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. +- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+. - `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). - `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). - `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index a6fb427bdc2d..d92dfda9c752 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -171,7 +171,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available - sender allowlists are evaluated before mention/reply activation - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cda126f56491..8e5d8ab0382a 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. Direct messages only; groups coming soon per Zalo docs. +Status: experimental. DMs are supported; group handling is available with explicit group policy controls. ## Plugin required @@ -51,7 +51,7 @@ It is a good fit for support or notifications where you want deterministic routi - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are not yet supported (Zalo docs state "coming soon"). +- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. ## Setup (fast path) @@ -107,6 +107,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and - Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available). +## Access control (Groups) + +- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. +- Default behavior is fail-closed: `allowlist`. +- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. +- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. +- `groupPolicy: "disabled"` blocks all group messages. +- `groupPolicy: "open"` allows any group member (mention-gated). +- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -130,16 +140,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Capabilities -| Feature | Status | -| --------------- | ------------------------------ | -| Direct messages | ✅ Supported | -| Groups | ❌ Coming soon (per Zalo docs) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +| Feature | Status | +| --------------- | -------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ⚠️ Supported with policy controls (allowlist by default) | +| Media (images) | ✅ Supported | +| Reactions | ❌ Not supported | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -172,6 +182,8 @@ Provider options: - `channels.zalo.tokenFile`: read token from file path. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). - `channels.zalo.webhookSecret`: webhook secret (8-256 chars). @@ -186,6 +198,8 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. - `channels.zalo.accounts..webhookPath`: per-account webhook path. diff --git a/docs/ci.md b/docs/ci.md index 64d4df0ec1c4..51643c870017 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,6 +1,10 @@ --- title: CI Pipeline description: How the OpenClaw CI pipeline works +summary: "CI job graph, scope gates, and local command equivalents" +read_when: + - You need to understand why a CI job did or did not run + - You are debugging failing GitHub Actions checks --- # CI Pipeline diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 9535509016d2..1b1981395e46 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -49,6 +49,13 @@ openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/ openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 ``` +Permission model (client debug mode): + +- Auto-approval is allowlist-based and only applies to trusted core tool IDs. +- `read` auto-approval is scoped to the current working directory (`--cwd` when set). +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval. +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source). + ## How to use this Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want diff --git a/docs/cli/agents.md b/docs/cli/agents.md index 39679265f14a..5bdc8a68bf21 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)" +summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)" read_when: - You want multiple isolated agents (workspaces + routing + auth) title: "agents" @@ -19,11 +19,59 @@ Related: ```bash openclaw agents list openclaw agents add work --workspace ~/.openclaw/workspace-work +openclaw agents bindings +openclaw agents bind --agent work --bind telegram:ops +openclaw agents unbind --agent work --bind telegram:ops openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity openclaw agents set-identity --agent main --avatar avatars/openclaw.png openclaw agents delete work ``` +## Routing bindings + +Use routing bindings to pin inbound channel traffic to a specific agent. + +List bindings: + +```bash +openclaw agents bindings +openclaw agents bindings --agent work +openclaw agents bindings --json +``` + +Add bindings: + +```bash +openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a +``` + +If you omit `accountId` (`--bind `), OpenClaw resolves it from channel defaults and plugin setup hooks when available. + +### Binding scope behavior + +- A binding without `accountId` matches the channel default account only. +- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding. +- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate. + +Example: + +```bash +# initial channel-only binding +openclaw agents bind --agent work --bind telegram + +# later upgrade to account-scoped binding +openclaw agents bind --agent work --bind telegram:ops +``` + +After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`). + +Remove bindings: + +```bash +openclaw agents unbind --agent work --bind telegram:ops +openclaw agents unbind --agent work --all +``` + ## Identity files Each agent workspace can include an `IDENTITY.md` at the workspace root: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 4213efb3eb7d..23e0b2cfd4be 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -35,6 +35,26 @@ openclaw channels remove --channel telegram --delete Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc). +When you run `openclaw channels add` without flags, the interactive wizard can prompt: + +- account ids per selected channel +- optional display names for those accounts +- `Bind configured channel accounts to agents now?` + +If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings. + +You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)). + +When you add a non-default account to a channel that is still using single-account top-level settings (no `channels..accounts` entries yet), OpenClaw moves account-scoped single-account top-level values into `channels..accounts.default`, then writes the new account. This preserves the original account behavior while moving to the multi-account shape. + +Routing behavior stays consistent: + +- Existing channel-only bindings (no `accountId`) continue to match the default account. +- `channels add` does not auto-create or rewrite bindings in non-interactive mode. +- Interactive setup can optionally add account-scoped bindings. + +If your config was already in a mixed state (named accounts present, missing `default`, and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into `accounts.default`. + ## Login / logout (interactive) ```bash diff --git a/docs/cli/clawbot.md b/docs/cli/clawbot.md new file mode 100644 index 000000000000..99468b45456b --- /dev/null +++ b/docs/cli/clawbot.md @@ -0,0 +1,21 @@ +--- +summary: "CLI reference for `openclaw clawbot` (legacy alias namespace)" +read_when: + - You maintain older scripts using `openclaw clawbot ...` + - You need migration guidance to current commands +title: "clawbot" +--- + +# `openclaw clawbot` + +Legacy alias namespace kept for backwards compatibility. + +Current supported alias: + +- `openclaw clawbot qr` (same behavior as [`openclaw qr`](/cli/qr)) + +## Migration + +Prefer modern top-level commands directly: + +- `openclaw clawbot qr` -> `openclaw qr` diff --git a/docs/cli/completion.md b/docs/cli/completion.md new file mode 100644 index 000000000000..7c052a6b25b7 --- /dev/null +++ b/docs/cli/completion.md @@ -0,0 +1,35 @@ +--- +summary: "CLI reference for `openclaw completion` (generate/install shell completion scripts)" +read_when: + - You want shell completions for zsh/bash/fish/PowerShell + - You need to cache completion scripts under OpenClaw state +title: "completion" +--- + +# `openclaw completion` + +Generate shell completion scripts and optionally install them into your shell profile. + +## Usage + +```bash +openclaw completion +openclaw completion --shell zsh +openclaw completion --install +openclaw completion --shell fish --install +openclaw completion --write-state +openclaw completion --shell bash --write-state +``` + +## Options + +- `-s, --shell `: shell target (`zsh`, `bash`, `powershell`, `fish`; default: `zsh`) +- `-i, --install`: install completion by adding a source line to your shell profile +- `--write-state`: write completion script(s) to `$OPENCLAW_STATE_DIR/completions` without printing to stdout +- `-y, --yes`: skip install confirmation prompts + +## Notes + +- `--install` writes a small "OpenClaw Completion" block into your shell profile and points it at the cached script. +- Without `--install` or `--write-state`, the command prints the script to stdout. +- Completion generation eagerly loads command trees so nested subcommands are included. diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 1590a0550501..0055abec7b49 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -29,5 +29,5 @@ Notes: ```bash openclaw configure -openclaw configure --section models --section channels +openclaw configure --section model --section channels ``` diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 3e56db9717a5..9c129518e213 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -23,6 +23,11 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after- Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. +Note: retention/pruning is controlled in config: + +- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md new file mode 100644 index 000000000000..4b5ebf45d071 --- /dev/null +++ b/docs/cli/daemon.md @@ -0,0 +1,43 @@ +--- +summary: "CLI reference for `openclaw daemon` (legacy alias for gateway service management)" +read_when: + - You still use `openclaw daemon ...` in scripts + - You need service lifecycle commands (install/start/stop/restart/status) +title: "daemon" +--- + +# `openclaw daemon` + +Legacy alias for Gateway service management commands. + +`openclaw daemon ...` maps to the same service control surface as `openclaw gateway ...` service commands. + +## Usage + +```bash +openclaw daemon status +openclaw daemon install +openclaw daemon start +openclaw daemon stop +openclaw daemon restart +openclaw daemon uninstall +``` + +## Subcommands + +- `status`: show service install state and probe Gateway health +- `install`: install service (`launchd`/`systemd`/`schtasks`) +- `uninstall`: remove service +- `start`: start service +- `stop`: stop service +- `restart`: restart service + +## Common options + +- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json` +- `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` +- lifecycle (`uninstall|start|stop|restart`): `--json` + +## Prefer + +Use [`openclaw gateway`](/cli/gateway) for current docs and examples. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index edacf9a2876e..be01e3cc0d52 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -21,6 +21,25 @@ openclaw devices list openclaw devices list --json ``` +### `openclaw devices remove ` + +Remove one paired device entry. + +``` +openclaw devices remove +openclaw devices remove --json +``` + +### `openclaw devices clear --yes [--pending]` + +Clear paired devices in bulk. + +``` +openclaw devices clear --yes +openclaw devices clear --yes --pending +openclaw devices clear --yes --pending --json +``` + ### `openclaw devices approve [requestId] [--latest]` Approve a pending device pairing request. If `requestId` is omitted, OpenClaw @@ -71,3 +90,5 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er - Token rotation returns a new token (sensitive). Treat it like a secret. - These commands require `operator.pairing` (or `operator.admin`) scope. +- `devices clear` is intentionally gated by `--yes`. +- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 7dc1f6fc1b8e..d53d86452f3b 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -27,6 +27,9 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. +- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. +- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. +- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). ## macOS: `launchctl` env overrides diff --git a/docs/cli/index.md b/docs/cli/index.md index 65448f4ee188..bb09b062210f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -16,6 +16,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`onboard`](/cli/onboard) - [`configure`](/cli/configure) - [`config`](/cli/config) +- [`completion`](/cli/completion) - [`doctor`](/cli/doctor) - [`dashboard`](/cli/dashboard) - [`reset`](/cli/reset) @@ -33,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`system`](/cli/system) - [`models`](/cli/models) - [`memory`](/cli/memory) +- [`directory`](/cli/directory) - [`nodes`](/cli/nodes) - [`devices`](/cli/devices) - [`node`](/cli/node) @@ -46,10 +48,14 @@ This page describes the current CLI behavior. If commands change, update this do - [`hooks`](/cli/hooks) - [`webhooks`](/cli/webhooks) - [`pairing`](/cli/pairing) +- [`qr`](/cli/qr) - [`plugins`](/cli/plugins) (plugin commands) - [`channels`](/cli/channels) - [`security`](/cli/security) +- [`secrets`](/cli/secrets) - [`skills`](/cli/skills) +- [`daemon`](/cli/daemon) (legacy alias for gateway service commands) +- [`clawbot`](/cli/clawbot) (legacy alias namespace) - [`voicecall`](/cli/voicecall) (plugin; if installed) ## Global flags @@ -94,9 +100,14 @@ openclaw [--dev] [--profile ] get set unset + completion doctor + dashboard security audit + secrets + reload + migrate reset uninstall update @@ -108,6 +119,7 @@ openclaw [--dev] [--profile ] remove login logout + directory skills list info @@ -145,6 +157,13 @@ openclaw [--dev] [--profile ] stop restart run + daemon + status + install + uninstall + start + stop + restart logs system event @@ -231,6 +250,9 @@ openclaw [--dev] [--profile ] pairing list approve + qr + clawbot + qr docs dns setup @@ -245,6 +267,13 @@ Note: plugins can add additional top-level commands (for example `openclaw voice - `openclaw security audit --deep` — best-effort live Gateway probe. - `openclaw security audit --fix` — tighten safe defaults and chmod state/config. +## Secrets + +- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot. +- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift. +- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply. +- `openclaw secrets apply --from ` — apply a previously generated plan (`--dry-run` supported). + ## Plugins Manage extensions and their config: @@ -263,7 +292,7 @@ Vector search over `MEMORY.md` + `memory/*.md`: - `openclaw memory status` — show index stats. - `openclaw memory index` — reindex memory files. -- `openclaw memory search ""` — semantic search over memory. +- `openclaw memory search ""` (or `--query ""`) — semantic search over memory. ## Chat slash commands @@ -299,17 +328,20 @@ Interactive wizard to set up gateway, workspace, and skills. Options: - `--workspace ` -- `--reset` (reset config + credentials + sessions + workspace before wizard) +- `--reset` (reset config + credentials + sessions before wizard) +- `--reset-scope ` (default `config+creds+sessions`; use `full` to also remove workspace) - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) - `--token-expires-in ` (non-interactive; e.g. `365d`, `12h`) +- `--secret-input-mode ` (default `plaintext`; use `ref` to store provider default env refs instead of plaintext keys) - `--anthropic-api-key ` - `--openai-api-key ` +- `--mistral-api-key ` - `--openrouter-api-key ` - `--ai-gateway-api-key ` - `--moonshot-api-key ` @@ -381,6 +413,8 @@ Subcommands: - Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `openclaw doctor`). - `channels logs`: show recent channel logs from the gateway log file. - `channels add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode. + - When adding a non-default account to a channel still using single-account top-level config, OpenClaw moves account-scoped values into `channels..accounts.default` before writing the new account. + - Non-interactive `channels add` does not auto-create/upgrade bindings; channel-only bindings continue to match the default account. - `channels remove`: disable by default; pass `--delete` to remove config entries without prompts. - `channels login`: interactive channel login (WhatsApp Web only). - `channels logout`: log out of a channel session (if supported). @@ -449,8 +483,23 @@ Approve DM pairing requests across channels. Subcommands: -- `pairing list [--json]` -- `pairing approve [--notify]` +- `pairing list [channel] [--channel ] [--account ] [--json]` +- `pairing approve [--account ] [--notify]` +- `pairing approve --channel [--account ] [--notify]` + +### `devices` + +Manage gateway device pairing entries and per-role device tokens. + +Subcommands: + +- `devices list [--json]` +- `devices approve [requestId] [--latest]` +- `devices reject ` +- `devices remove ` +- `devices clear --yes [--pending]` +- `devices rotate --device --role [--scope ]` +- `devices revoke --device --role ` ### `webhooks gmail` @@ -540,7 +589,37 @@ Options: - `--non-interactive` - `--json` -Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used. +Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope. + +#### `agents bindings` + +List routing bindings. + +Options: + +- `--agent ` +- `--json` + +#### `agents bind` + +Add routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--json` + +#### `agents unbind` + +Remove routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--all` +- `--json` #### `agents delete ` diff --git a/docs/cli/memory.md b/docs/cli/memory.md index bc6d05c12e3c..11b9926c56a7 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -26,6 +26,7 @@ openclaw memory status --deep --index --verbose openclaw memory index openclaw memory index --verbose openclaw memory search "release checklist" +openclaw memory search --query "release checklist" openclaw memory status --agent main openclaw memory index --agent main --verbose ``` @@ -37,6 +38,12 @@ Common: - `--agent `: scope to a single agent (default: all configured agents). - `--verbose`: emit detailed logs during probes and indexing. +`memory search`: + +- Query input: pass either positional `[query]` or `--query `. +- If both are provided, `--query` wins. +- If neither is provided, the command exits with an error. + Notes: - `memory status --deep` probes vector + embedding availability. diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 59c8a342d35a..1bc8fd90c2c9 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -69,5 +69,7 @@ Flags: - `--invoke-timeout `: node invoke timeout (default `30000`). - `--needs-screen-recording`: require screen recording permission. - `--raw `: run a shell string (`/bin/sh -lc` or `cmd.exe /c`). + In allowlist mode on Windows node hosts, `cmd.exe /c` shell-wrapper runs require approval + (allowlist entry alone does not auto-allow the wrapper form). - `--agent `: agent-scoped approvals/allowlists (defaults to configured agent). - `--ask `, `--security `: overrides. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index ee6f147f288c..7485499d1eaa 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -34,11 +34,39 @@ openclaw onboard --non-interactive \ --custom-base-url "https://llm.example.com/v1" \ --custom-model-id "foo-large" \ --custom-api-key "$CUSTOM_API_KEY" \ + --secret-input-mode plaintext \ --custom-compatibility openai ``` `--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. +Store provider keys as refs instead of plaintext: + +```bash +openclaw onboard --non-interactive \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --accept-risk +``` + +With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values. +For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers..apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). + +Non-interactive `ref` mode contract: + +- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`). +- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set. +- If an inline key flag is passed without the required env var, onboarding fails fast with guidance. + +Interactive onboarding behavior with reference mode: + +- Choose **Use secret reference** when prompted. +- Then choose either: + - Environment variable + - Configured secret provider (`file` or `exec`) +- Onboarding performs a fast preflight validation before saving the ref. + - If validation fails, onboarding shows the error and lets you retry. + Non-interactive Z.AI endpoint choices: Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`). @@ -56,10 +84,19 @@ openclaw onboard --non-interactive \ # --auth-choice zai-cn ``` +Non-interactive Mistral example: + +```bash +openclaw onboard --non-interactive \ + --auth-choice mistral-api-key \ + --mistral-api-key "$MISTRAL_API_KEY" +``` + Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. - `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Local onboarding DM scope behavior: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals). - Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). - Custom Provider: connect any OpenAI or Anthropic compatible endpoint, including hosted providers not listed. Use Unknown to auto-detect. diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 319ddc29a0f6..13ad8a59948b 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -16,6 +16,17 @@ Related: ## Commands ```bash -openclaw pairing list whatsapp -openclaw pairing approve whatsapp --notify +openclaw pairing list telegram +openclaw pairing list --channel telegram --account work +openclaw pairing list telegram --json + +openclaw pairing approve telegram +openclaw pairing approve --channel telegram --account work --notify ``` + +## Notes + +- Channel input: pass it positionally (`pairing list telegram`) or with `--channel `. +- `pairing list` supports `--account ` for multi-account channels. +- `pairing approve` supports `--account ` and `--notify`. +- If only one pairing-capable channel is configured, `pairing approve ` is allowed. diff --git a/docs/cli/qr.md b/docs/cli/qr.md new file mode 100644 index 000000000000..109628264f68 --- /dev/null +++ b/docs/cli/qr.md @@ -0,0 +1,39 @@ +--- +summary: "CLI reference for `openclaw qr` (generate iOS pairing QR + setup code)" +read_when: + - You want to pair the iOS app with a gateway quickly + - You need setup-code output for remote/manual sharing +title: "qr" +--- + +# `openclaw qr` + +Generate an iOS pairing QR and setup code from your current Gateway configuration. + +## Usage + +```bash +openclaw qr +openclaw qr --setup-code-only +openclaw qr --json +openclaw qr --remote +openclaw qr --url wss://gateway.example/ws --token '' +``` + +## Options + +- `--remote`: use `gateway.remote.url` plus remote token/password from config +- `--url `: override gateway URL used in payload +- `--public-url `: override public URL used in payload +- `--token `: override gateway token for payload +- `--password `: override gateway password for payload +- `--setup-code-only`: print only setup code +- `--no-ascii`: skip ASCII QR rendering +- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`) + +## Notes + +- `--token` and `--password` are mutually exclusive. +- After scanning, approve device pairing with: + - `openclaw devices list` + - `openclaw devices approve ` diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md new file mode 100644 index 000000000000..43bb4c9b9773 --- /dev/null +++ b/docs/cli/secrets.md @@ -0,0 +1,119 @@ +--- +summary: "CLI reference for `openclaw secrets` (reload, audit, configure, apply)" +read_when: + - Re-resolving secret refs at runtime + - Auditing plaintext residues and unresolved refs + - Configuring SecretRefs and applying one-way scrub changes +title: "secrets" +--- + +# `openclaw secrets` + +Secrets runtime controls. + +Related: + +- Secrets guide: [Secrets Management](/gateway/secrets) +- Security guide: [Security](/gateway/security) + +## Reload runtime snapshot + +Re-resolve secret refs and atomically swap runtime snapshot. + +```bash +openclaw secrets reload +openclaw secrets reload --json +``` + +Notes: + +- Uses gateway RPC method `secrets.reload`. +- If resolution fails, gateway keeps last-known-good snapshot. +- JSON response includes `warningCount`. + +## Audit + +Scan OpenClaw state for: + +- plaintext secret storage +- unresolved refs +- precedence drift (`auth-profiles` shadowing config refs) +- legacy residues (`auth.json`, OAuth out-of-scope notes) + +```bash +openclaw secrets audit +openclaw secrets audit --check +openclaw secrets audit --json +``` + +Exit behavior: + +- `--check` exits non-zero on findings. +- unresolved refs exit with a higher-priority non-zero code. + +## Configure (interactive helper) + +Build provider + SecretRef changes interactively, run preflight, and optionally apply: + +```bash +openclaw secrets configure +openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json +openclaw secrets configure --apply --yes +openclaw secrets configure --providers-only +openclaw secrets configure --skip-provider-setup +openclaw secrets configure --json +``` + +Flow: + +- Provider setup first (`add/edit/remove` for `secrets.providers` aliases). +- Credential mapping second (select fields and assign `{source, provider, id}` refs). +- Preflight and optional apply last. + +Flags: + +- `--providers-only`: configure `secrets.providers` only, skip credential mapping. +- `--skip-provider-setup`: skip provider setup and map credentials to existing providers. + +Notes: + +- `configure` targets secret-bearing fields in `openclaw.json`. +- Include all secret-bearing fields you intend to migrate (for example both `models.providers.*.apiKey` and `skills.entries.*.apiKey`) so audit can reach a clean state. +- It performs preflight resolution before apply. +- Apply path is one-way for migrated plaintext values. + +Exec provider safety note: + +- Homebrew installs often expose symlinked binaries under `/opt/homebrew/bin/*`. +- Set `allowSymlinkCommand: true` only when needed for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`). + +## Apply a saved plan + +Apply or preflight a plan generated previously: + +```bash +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json +``` + +Plan contract details (allowed target paths, validation rules, and failure semantics): + +- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) + +## Why no rollback backups + +`secrets apply` intentionally does not write rollback backups containing old plaintext values. + +Safety comes from strict preflight + atomic-ish apply with best-effort in-memory restore on failure. + +## Example + +```bash +# Audit first, then configure, then confirm clean: +openclaw secrets audit --check +openclaw secrets configure +openclaw secrets audit --check +``` + +If `audit --check` still reports plaintext findings after a partial migration, verify you also migrated skill keys (`skills.entries.*.apiKey`) and any other reported target paths. diff --git a/docs/cli/security.md b/docs/cli/security.md index 84f8c40806cc..cc705b31a30d 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,14 +25,20 @@ openclaw security audit --json The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts). +It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. +For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. -It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. +It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. +It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. +It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins). It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. -It warns when Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs. +It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). +Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. +For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). ## JSON output diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 0709bc1f0df3..4ed5ace54ee3 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -11,6 +11,94 @@ List stored conversation sessions. ```bash openclaw sessions +openclaw sessions --agent work +openclaw sessions --all-agents openclaw sessions --active 120 openclaw sessions --json ``` + +Scope selection: + +- default: configured default agent store +- `--agent `: one configured agent store +- `--all-agents`: aggregate all configured agent stores +- `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) + +JSON examples: + +`openclaw sessions --all-agents --json`: + +```json +{ + "path": null, + "stores": [ + { "agentId": "main", "path": "/home/user/.openclaw/agents/main/sessions/sessions.json" }, + { "agentId": "work", "path": "/home/user/.openclaw/agents/work/sessions/sessions.json" } + ], + "allAgents": true, + "count": 2, + "activeMinutes": null, + "sessions": [ + { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, + { "agentId": "work", "key": "agent:work:main", "model": "claude-opus-4-5" } + ] +} +``` + +## Cleanup maintenance + +Run maintenance now (instead of waiting for the next write cycle): + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --agent work --dry-run +openclaw sessions cleanup --all-agents --dry-run +openclaw sessions cleanup --enforce +openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123" +openclaw sessions cleanup --json +``` + +`openclaw sessions cleanup` uses `session.maintenance` settings from config: + +- Scope note: `openclaw sessions cleanup` maintains session stores/transcripts only. It does not prune cron run logs (`cron/runs/.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance). + +- `--dry-run`: preview how many entries would be pruned/capped without writing. + - In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed. +- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`. +- `--active-key `: protect a specific active key from disk-budget eviction. +- `--agent `: run cleanup for one configured agent store. +- `--all-agents`: run cleanup for all configured agent stores. +- `--store `: run against a specific `sessions.json` file. +- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store. + +`openclaw sessions cleanup --all-agents --dry-run --json`: + +```json +{ + "allAgents": true, + "mode": "warn", + "dryRun": true, + "stores": [ + { + "agentId": "main", + "storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json", + "beforeCount": 120, + "afterCount": 80, + "pruned": 40, + "capped": 0 + }, + { + "agentId": "work", + "storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json", + "beforeCount": 18, + "afterCount": 18, + "pruned": 0, + "capped": 0 + } + ] +} +``` + +Related: + +- Session config: [Configuration reference](/gateway/configuration-reference#session) diff --git a/docs/cli/update.md b/docs/cli/update.md index 5dfd97f9a8de..7a1840096f2c 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -21,6 +21,7 @@ openclaw update wizard openclaw update --channel beta openclaw update --channel dev openclaw update --tag beta +openclaw update --dry-run openclaw update --no-restart openclaw update --json openclaw --update @@ -31,6 +32,7 @@ openclaw --update - `--no-restart`: skip restarting the Gateway service after a successful update. - `--channel `: set the update channel (git + npm; persisted in config). - `--tag `: override the npm dist-tag or version for this update only. +- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: per-step timeout (default is 1200s). @@ -66,6 +68,8 @@ install method aligned: updates it, and installs the global CLI from that checkout. - `stable`/`beta` → installs from npm using the matching dist-tag. +The Gateway core auto-updater (when enabled via config) reuses this same update path. + ## Git checkout flow Channels: diff --git a/docs/cli/voicecall.md b/docs/cli/voicecall.md index 52da8d9635b6..7e62ff0589e9 100644 --- a/docs/cli/voicecall.md +++ b/docs/cli/voicecall.md @@ -28,7 +28,7 @@ openclaw voicecall end --call-id ```bash openclaw voicecall expose --mode serve openclaw voicecall expose --mode funnel -openclaw voicecall unexpose +openclaw voicecall expose --mode off ``` Security note: only expose the webhook endpoint to networks you trust. Prefer Tailscale Serve over Funnel when possible. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index de9582c71445..a36c93313c6f 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -97,8 +97,11 @@ sequenceDiagram for subsequent connects. - **Local** connects (loopback or the gateway host’s own tailnet address) can be auto‑approved to keep same‑host UX smooth. -- **Non‑local** connects must sign the `connect.challenge` nonce and require - explicit approval. +- All connects must sign the `connect.challenge` nonce. +- Signature payload `v3` also binds `platform` + `deviceFamily`; the gateway + pins paired metadata on reconnect and requires repair pairing for metadata + changes. +- **Non‑local** connects still require explicit approval. - Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or remote. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 66194ef5e0e3..c8b2db0b091c 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -105,7 +105,8 @@ Defaults: 2. `openai` if an OpenAI key can be resolved. 3. `gemini` if a Gemini key can be resolved. 4. `voyage` if a Voyage key can be resolved. - 5. Otherwise memory search stays disabled until configured. + 5. `mistral` if a Mistral key can be resolved. + 6. Otherwise memory search stays disabled until configured. - Local mode uses node-llama-cpp and may require `pnpm approve-builds`. - Uses sqlite-vec (when available) to accelerate vector search inside SQLite. @@ -114,7 +115,9 @@ resolves keys from auth profiles, `models.providers.*.apiKey`, or environment variables. Codex OAuth only covers chat/completions and does **not** satisfy embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or `models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or -`models.providers.voyage.apiKey`. When using a custom OpenAI-compatible endpoint, +`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or +`models.providers.mistral.apiKey`. +When using a custom OpenAI-compatible endpoint, set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). ### QMD backend (experimental) @@ -328,7 +331,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se Fallbacks: -- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`. +- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`. - The fallback provider is only used when the primary embedding provider fails. Batch indexing (OpenAI + Gemini + Voyage): diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index c8037d63935e..fccd0b842493 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -70,6 +70,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Auth: OAuth (ChatGPT) - Example model: `openai-codex/gpt-5.3-codex` - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` +- Default transport is `auto` (WebSocket-first, SSE fallback) +- Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) ```json5 { @@ -102,6 +104,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli` - Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows +- Caution: Antigravity and Gemini CLI OAuth in OpenClaw are unofficial integrations. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. - Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default). - Enable: `openclaw plugins enable google-antigravity-auth` - Login: `openclaw models auth login --provider google-antigravity --set-default` @@ -126,16 +129,31 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6` - CLI: `openclaw onboard --auth-choice ai-gateway-api-key` +### Kilo Gateway + +- Provider: `kilocode` +- Auth: `KILOCODE_API_KEY` +- Example model: `kilocode/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --kilocode-api-key ` +- Base URL: `https://api.kilo.ai/api/gateway/` +- Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5. + +See [/providers/kilocode](/providers/kilocode) for setup details. + ### Other built-in providers - OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) - Example model: `openrouter/anthropic/claude-sonnet-4-5` +- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) +- Example model: `kilocode/anthropic/claude-opus-4.6` - xAI: `xai` (`XAI_API_KEY`) +- Mistral: `mistral` (`MISTRAL_API_KEY`) +- Example model: `mistral/mistral-large-latest` +- CLI: `openclaw onboard --auth-choice mistral-api-key` - Groq: `groq` (`GROQ_API_KEY`) - Cerebras: `cerebras` (`CEREBRAS_API_KEY`) - GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`. - OpenAI-compatible base URL: `https://api.cerebras.ai/v1`. -- Mistral: `mistral` (`MISTRAL_API_KEY`) - GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`) - Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface). diff --git a/docs/concepts/models.md b/docs/concepts/models.md index ee8f06ecb3d4..b4317273d5c8 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -207,3 +207,9 @@ mode, pass `--yes` to accept defaults. Custom providers in `models.providers` are written into `models.json` under the agent directory (default `~/.openclaw/agents//models.json`). This file is merged by default unless `models.mode` is set to `replace`. + +Merge mode precedence for matching provider IDs: + +- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win. +- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`. +- Other provider fields are refreshed from config and normalized catalog data. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 069fcfb63679..842531cc2a68 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**: If multiple bindings match in the same tier, the first one in config order wins. If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics). +Important account-scope detail: + +- A binding that omits `accountId` matches the default account only. +- Use `accountId: "*"` for a channel-wide fallback across all accounts. +- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it. + ## Multiple accounts / phone numbers Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 586406cf6b11..741867f188f3 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -40,8 +40,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**: Secrets are stored **per-agent**: -- Auth profiles (OAuth + API keys): `~/.openclaw/agents//agent/auth-profiles.json` -- Runtime cache (managed automatically; don’t edit): `~/.openclaw/agents//agent/auth.json` +- Auth profiles (OAuth + API keys + optional value-level refs): `~/.openclaw/agents//agent/auth-profiles.json` +- Legacy compatibility file: `~/.openclaw/agents//agent/auth.json` + (static `api_key` entries are scrubbed when discovered) Legacy import-only file (still supported, but not the main store): @@ -49,6 +50,8 @@ Legacy import-only file (still supported, but not the main store): All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys) +For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets). + ## Anthropic setup-token (subscription auth) Run `claude setup-token` on any machine, then paste it into OpenClaw: diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index 0fcb2b78d0a8..ba9f39f37f13 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -15,13 +15,13 @@ Session pruning trims **old tool results** from the in-memory context right befo - When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`. - Only affects the messages sent to the model for that request. - Only active for Anthropic API calls (and OpenRouter Anthropic models). -- For best results, match `ttl` to your model `cacheControlTtl`. +- For best results, match `ttl` to your model `cacheRetention` policy (`short` = 5m, `long` = 1h). - After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. ## Smart defaults (Anthropic) - **OAuth or setup-token** profiles: enable `cache-ttl` pruning and set heartbeat to `1h`. -- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheControlTtl` to `1h` on Anthropic models. +- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheRetention: "short"` on Anthropic models. - If you set any of these values explicitly, OpenClaw does **not** override them. ## What this improves (cost + cache behavior) @@ -91,9 +91,7 @@ Default (off): ```json5 { - agent: { - contextPruning: { mode: "off" }, - }, + agents: { defaults: { contextPruning: { mode: "off" } } }, } ``` @@ -101,9 +99,7 @@ Enable TTL-aware pruning: ```json5 { - agent: { - contextPruning: { mode: "cache-ttl", ttl: "5m" }, - }, + agents: { defaults: { contextPruning: { mode: "cache-ttl", ttl: "5m" } } }, } ``` @@ -111,10 +107,12 @@ Restrict pruning to specific tools: ```json5 { - agent: { - contextPruning: { - mode: "cache-ttl", - tools: { allow: ["exec", "read"], deny: ["*image*"] }, + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + tools: { allow: ["exec", "read"], deny: ["*image*"] }, + }, }, }, } diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe557..bbd58d599ced 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -152,7 +152,7 @@ Parameters: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, aborts the sub-agent run after N seconds) - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index edd6f415d285..6c9010d2c11e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -49,6 +49,7 @@ Use `session.dmScope` to control how **direct messages** are grouped: Notes: - Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups. +- Local CLI onboarding writes `session.dmScope: "per-channel-peer"` by default when unset (existing explicit values are preserved). - For multi-account inboxes on the same channel, prefer `per-account-channel-peer`. - If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity. - You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)). @@ -70,6 +71,109 @@ All session state is **owned by the gateway** (the “master” OpenClaw). UI cl - Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from. - OpenClaw does **not** read legacy Pi/Tau session folders. +## Maintenance + +OpenClaw applies session-store maintenance to keep `sessions.json` and transcript artifacts bounded over time. + +### Defaults + +- `session.maintenance.mode`: `warn` +- `session.maintenance.pruneAfter`: `30d` +- `session.maintenance.maxEntries`: `500` +- `session.maintenance.rotateBytes`: `10mb` +- `session.maintenance.resetArchiveRetention`: defaults to `pruneAfter` (`30d`) +- `session.maintenance.maxDiskBytes`: unset (disabled) +- `session.maintenance.highWaterBytes`: defaults to `80%` of `maxDiskBytes` when budgeting is enabled + +### How it works + +Maintenance runs during session-store writes, and you can trigger it on demand with `openclaw sessions cleanup`. + +- `mode: "warn"`: reports what would be evicted but does not mutate entries/transcripts. +- `mode: "enforce"`: applies cleanup in this order: + 1. prune stale entries older than `pruneAfter` + 2. cap entry count to `maxEntries` (oldest first) + 3. archive transcript files for removed entries that are no longer referenced + 4. purge old `*.deleted.` and `*.reset.` archives by retention policy + 5. rotate `sessions.json` when it exceeds `rotateBytes` + 6. if `maxDiskBytes` is set, enforce disk budget toward `highWaterBytes` (oldest artifacts first, then oldest sessions) + +### Performance caveat for large stores + +Large session stores are common in high-volume setups. Maintenance work is write-path work, so very large stores can increase write latency. + +What increases cost most: + +- very high `session.maintenance.maxEntries` values +- long `pruneAfter` windows that keep stale entries around +- many transcript/archive artifacts in `~/.openclaw/agents//sessions/` +- enabling disk budgets (`maxDiskBytes`) without reasonable pruning/cap limits + +What to do: + +- use `mode: "enforce"` in production so growth is bounded automatically +- set both time and count limits (`pruneAfter` + `maxEntries`), not just one +- set `maxDiskBytes` + `highWaterBytes` for hard upper bounds in large deployments +- keep `highWaterBytes` meaningfully below `maxDiskBytes` (default is 80%) +- run `openclaw sessions cleanup --dry-run --json` after config changes to verify projected impact before enforcing +- for frequent active sessions, pass `--active-key` when running manual cleanup + +### Customize examples + +Use a conservative enforce policy: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "45d", + maxEntries: 800, + rotateBytes: "20mb", + resetArchiveRetention: "14d", + }, + }, +} +``` + +Enable a hard disk budget for the sessions directory: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + maxDiskBytes: "1gb", + highWaterBytes: "800mb", + }, + }, +} +``` + +Tune for larger installs (example): + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "14d", + maxEntries: 2000, + rotateBytes: "25mb", + maxDiskBytes: "2gb", + highWaterBytes: "1.6gb", + }, + }, +} +``` + +Preview or force maintenance from CLI: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + ## Session pruning OpenClaw trims **old tool results** from the in-memory context right before LLM calls by default. @@ -179,7 +283,7 @@ Runtime override (owner only): - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). -- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). +- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - JSONL transcripts can be opened directly to review full turns. diff --git a/docs/concepts/sessions.md b/docs/concepts/sessions.md index f216c0c9f662..6bc0c8e3501a 100644 --- a/docs/concepts/sessions.md +++ b/docs/concepts/sessions.md @@ -1,7 +1,7 @@ --- summary: "Alias for session management docs" read_when: - - You looked for docs/sessions.md; canonical doc lives in docs/session.md + - You looked for docs/concepts/sessions.md; canonical doc lives in docs/concepts/session.md title: "Sessions" --- diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md new file mode 100644 index 000000000000..596a77f13858 --- /dev/null +++ b/docs/design/kilo-gateway-integration.md @@ -0,0 +1,534 @@ +# Kilo Gateway Provider Integration Design + +## Overview + +This document outlines the design for integrating "Kilo Gateway" as a first-class provider in OpenClaw, modeled after the existing OpenRouter implementation. Kilo Gateway uses an OpenAI-compatible completions API with a different base URL. + +## Design Decisions + +### 1. Provider Naming + +**Recommendation: `kilocode`** + +Rationale: + +- Matches the user config example provided (`kilocode` provider key) +- Consistent with existing provider naming patterns (e.g., `openrouter`, `opencode`, `moonshot`) +- Short and memorable +- Avoids confusion with generic "kilo" or "gateway" terms + +Alternative considered: `kilo-gateway` - rejected because hyphenated names are less common in the codebase and `kilocode` is more concise. + +### 2. Default Model Reference + +**Recommendation: `kilocode/anthropic/claude-opus-4.6`** + +Rationale: + +- Based on user config example +- Claude Opus 4.5 is a capable default model +- Explicit model selection avoids reliance on auto-routing + +### 3. Base URL Configuration + +**Recommendation: Hardcoded default with config override** + +- **Default Base URL:** `https://api.kilo.ai/api/gateway/` +- **Configurable:** Yes, via `models.providers.kilocode.baseUrl` + +This matches the pattern used by other providers like Moonshot, Venice, and Synthetic. + +### 4. Model Scanning + +**Recommendation: No dedicated model scanning endpoint initially** + +Rationale: + +- Kilo Gateway proxies to OpenRouter, so models are dynamic +- Users can manually configure models in their config +- If Kilo Gateway exposes a `/models` endpoint in the future, scanning can be added + +### 5. Special Handling + +**Recommendation: Inherit OpenRouter behavior for Anthropic models** + +Since Kilo Gateway proxies to OpenRouter, the same special handling should apply: + +- Cache TTL eligibility for `anthropic/*` models +- Extra params (cacheControlTtl) for `anthropic/*` models +- Transcript policy follows OpenRouter patterns + +## Files to Modify + +### Core Credential Management + +#### 1. `src/commands/onboard-auth.credentials.ts` + +Add: + +```typescript +export const KILOCODE_DEFAULT_MODEL_REF = "kilocode/anthropic/claude-opus-4.6"; + +export async function setKilocodeApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "kilocode:default", + credential: { + type: "api_key", + provider: "kilocode", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} +``` + +#### 2. `src/agents/model-auth.ts` + +Add to `envMap` in `resolveEnvApiKey()`: + +```typescript +const envMap: Record = { + // ... existing entries + kilocode: "KILOCODE_API_KEY", +}; +``` + +#### 3. `src/config/io.ts` + +Add to `SHELL_ENV_EXPECTED_KEYS`: + +```typescript +const SHELL_ENV_EXPECTED_KEYS = [ + // ... existing entries + "KILOCODE_API_KEY", +]; +``` + +### Config Application + +#### 4. `src/commands/onboard-auth.config-core.ts` + +Add new functions: + +```typescript +export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; + +export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KILOCODE_DEFAULT_MODEL_REF] = { + ...models[KILOCODE_DEFAULT_MODEL_REF], + alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.kilocode; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + providers.kilocode = { + ...existingProviderRest, + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyKilocodeProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: KILOCODE_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} +``` + +### Auth Choice System + +#### 5. `src/commands/onboard-types.ts` + +Add to `AuthChoice` type: + +```typescript +export type AuthChoice = + // ... existing choices + "kilocode-api-key"; +// ... +``` + +Add to `OnboardOptions`: + +```typescript +export type OnboardOptions = { + // ... existing options + kilocodeApiKey?: string; + // ... +}; +``` + +#### 6. `src/commands/auth-choice-options.ts` + +Add to `AuthChoiceGroupId`: + +```typescript +export type AuthChoiceGroupId = + // ... existing groups + "kilocode"; +// ... +``` + +Add to `AUTH_CHOICE_GROUP_DEFS`: + +```typescript +{ + value: "kilocode", + label: "Kilo Gateway", + hint: "API key (OpenRouter-compatible)", + choices: ["kilocode-api-key"], +}, +``` + +Add to `buildAuthChoiceOptions()`: + +```typescript +options.push({ + value: "kilocode-api-key", + label: "Kilo Gateway API key", + hint: "OpenRouter-compatible gateway", +}); +``` + +#### 7. `src/commands/auth-choice.preferred-provider.ts` + +Add mapping: + +```typescript +const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { + // ... existing mappings + "kilocode-api-key": "kilocode", +}; +``` + +### Auth Choice Application + +#### 8. `src/commands/auth-choice.apply.api-providers.ts` + +Add import: + +```typescript +import { + // ... existing imports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.js"; +``` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "kilocode", + }); + const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); + const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + let profileId = "kilocode:default"; + let mode: "api_key" | "oauth" | "token" = "api_key"; + let hasCredential = false; + + if (existingProfileId && existingCred?.type) { + profileId = existingProfileId; + mode = + existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; + hasCredential = true; + } + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kilocode") { + await setKilocodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("kilocode"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing KILOCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setKilocodeApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Kilo Gateway API key", + validate: validateApiKeyInput, + }); + await setKilocodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "kilocode", + mode, + }); + } + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: KILOCODE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyKilocodeConfig, + applyProviderConfig: applyKilocodeProviderConfig, + noteDefault: KILOCODE_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; +} +``` + +Also add tokenProvider mapping at the top of the function: + +```typescript +if (params.opts.tokenProvider === "kilocode") { + authChoice = "kilocode-api-key"; +} +``` + +### CLI Registration + +#### 9. `src/cli/program/register.onboard.ts` + +Add CLI option: + +```typescript +.option("--kilocode-api-key ", "Kilo Gateway API key") +``` + +Add to action handler: + +```typescript +kilocodeApiKey: opts.kilocodeApiKey as string | undefined, +``` + +Update auth-choice help text: + +```typescript +.option( + "--auth-choice ", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|kilocode-api-key|ai-gateway-api-key|...", +) +``` + +### Non-Interactive Onboarding + +#### 10. `src/commands/onboard-non-interactive/local/auth-choice.ts` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "kilocode", + cfg: baseConfig, + flagValue: opts.kilocodeApiKey, + flagName: "--kilocode-api-key", + envVar: "KILOCODE_API_KEY", + }); + await setKilocodeApiKey(resolved.apiKey, agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "kilocode:default", + provider: "kilocode", + mode: "api_key", + }); + // ... apply default model +} +``` + +### Export Updates + +#### 11. `src/commands/onboard-auth.ts` + +Add exports: + +```typescript +export { + // ... existing exports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_BASE_URL, +} from "./onboard-auth.config-core.js"; + +export { + // ... existing exports + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.credentials.js"; +``` + +### Special Handling (Optional) + +#### 12. `src/agents/pi-embedded-runner/cache-ttl.ts` + +Add Kilo Gateway support for Anthropic models: + +```typescript +export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { + const normalizedProvider = provider.toLowerCase(); + const normalizedModelId = modelId.toLowerCase(); + if (normalizedProvider === "anthropic") return true; + if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) + return true; + if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) return true; + return false; +} +``` + +#### 13. `src/agents/transcript-policy.ts` + +Add Kilo Gateway handling (similar to OpenRouter): + +```typescript +const isKilocodeGemini = provider === "kilocode" && modelId.toLowerCase().includes("gemini"); + +// Include in needsNonImageSanitize check +const needsNonImageSanitize = + isGoogle || isAnthropic || isMistral || isOpenRouterGemini || isKilocodeGemini; +``` + +## Configuration Structure + +### User Config Example + +```json +{ + "models": { + "mode": "merge", + "providers": { + "kilocode": { + "baseUrl": "https://api.kilo.ai/api/gateway/", + "apiKey": "xxxxx", + "api": "openai-completions", + "models": [ + { + "id": "anthropic/claude-opus-4.6", + "name": "Anthropic: Claude Opus 4.6" + }, + { "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" } + ] + } + } + } +} +``` + +### Auth Profile Structure + +```json +{ + "profiles": { + "kilocode:default": { + "type": "api_key", + "provider": "kilocode", + "key": "xxxxx" + } + } +} +``` + +## Testing Considerations + +1. **Unit Tests:** + - Test `setKilocodeApiKey()` writes correct profile + - Test `applyKilocodeConfig()` sets correct defaults + - Test `resolveEnvApiKey("kilocode")` returns correct env var + +2. **Integration Tests:** + - Test onboarding flow with `--auth-choice kilocode-api-key` + - Test non-interactive onboarding with `--kilocode-api-key` + - Test model selection with `kilocode/` prefix + +3. **E2E Tests:** + - Test actual API calls through Kilo Gateway (live tests) + +## Migration Notes + +- No migration needed for existing users +- New users can immediately use `kilocode-api-key` auth choice +- Existing manual config with `kilocode` provider will continue to work + +## Future Considerations + +1. **Model Catalog:** If Kilo Gateway exposes a `/models` endpoint, add scanning support similar to `scanOpenRouterModels()` + +2. **OAuth Support:** If Kilo Gateway adds OAuth, extend the auth system accordingly + +3. **Rate Limiting:** Consider adding rate limit handling specific to Kilo Gateway if needed + +4. **Documentation:** Add docs at `docs/providers/kilocode.md` explaining setup and usage + +## Summary of Changes + +| File | Change Type | Description | +| ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | +| `src/commands/onboard-auth.credentials.ts` | Add | `KILOCODE_DEFAULT_MODEL_REF`, `setKilocodeApiKey()` | +| `src/agents/model-auth.ts` | Modify | Add `kilocode` to `envMap` | +| `src/config/io.ts` | Modify | Add `KILOCODE_API_KEY` to shell env keys | +| `src/commands/onboard-auth.config-core.ts` | Add | `applyKilocodeProviderConfig()`, `applyKilocodeConfig()` | +| `src/commands/onboard-types.ts` | Modify | Add `kilocode-api-key` to `AuthChoice`, add `kilocodeApiKey` to options | +| `src/commands/auth-choice-options.ts` | Modify | Add `kilocode` group and option | +| `src/commands/auth-choice.preferred-provider.ts` | Modify | Add `kilocode-api-key` mapping | +| `src/commands/auth-choice.apply.api-providers.ts` | Modify | Add `kilocode-api-key` handling | +| `src/cli/program/register.onboard.ts` | Modify | Add `--kilocode-api-key` option | +| `src/commands/onboard-non-interactive/local/auth-choice.ts` | Modify | Add non-interactive handling | +| `src/commands/onboard-auth.ts` | Modify | Export new functions | +| `src/agents/pi-embedded-runner/cache-ttl.ts` | Modify | Add kilocode support | +| `src/agents/transcript-policy.ts` | Modify | Add kilocode Gemini handling | diff --git a/docs/docs.json b/docs/docs.json index 604175337139..a6329ce0e062 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -91,6 +91,10 @@ "source": "/moonshot", "destination": "/providers/moonshot" }, + { + "source": "/mistral", + "destination": "/providers/mistral" + }, { "source": "/openrouter", "destination": "/providers/openrouter" @@ -271,6 +275,10 @@ "source": "/start/clawd/", "destination": "/start/openclaw" }, + { + "source": "/start/pairing", + "destination": "/channels/pairing" + }, { "source": "/clawhub", "destination": "/tools/clawhub" @@ -524,12 +532,12 @@ "destination": "/channels/pairing" }, { - "source": "/plans/cron-add-hardening", - "destination": "/experiments/plans/cron-add-hardening" + "source": "/experiments/plans/cron-add-hardening", + "destination": "/automation/cron-jobs" }, { - "source": "/plans/group-policy-hardening", - "destination": "/experiments/plans/group-policy-hardening" + "source": "/experiments/plans/group-policy-hardening", + "destination": "/channels/groups" }, { "source": "/poll", @@ -891,9 +899,15 @@ "channels/mattermost", "channels/signal", "channels/imessage", + "channels/bluebubbles", "channels/msteams", + "channels/synology-chat", "channels/line", "channels/matrix", + "channels/nextcloud-talk", + "channels/nostr", + "channels/tlon", + "channels/twitch", "channels/zalo", "channels/zalouser" ] @@ -988,7 +1002,12 @@ }, { "group": "Agent coordination", - "pages": ["tools/agent-send", "tools/subagents", "tools/multi-agent-sandbox-tools"] + "pages": [ + "tools/agent-send", + "tools/subagents", + "tools/acp-agents", + "tools/multi-agent-sandbox-tools" + ] }, { "group": "Skills", @@ -1057,6 +1076,7 @@ "providers/bedrock", "providers/vercel-ai-gateway", "providers/moonshot", + "providers/mistral", "providers/minimax", "providers/opencode", "providers/glm", @@ -1120,6 +1140,8 @@ "gateway/configuration-reference", "gateway/configuration-examples", "gateway/authentication", + "gateway/secrets", + "gateway/secrets-plan-contract", "gateway/trusted-proxy-auth", "gateway/health", "gateway/heartbeat", @@ -1182,14 +1204,20 @@ "group": "CLI commands", "pages": [ "cli/index", + "cli/acp", "cli/agent", "cli/agents", "cli/approvals", "cli/browser", "cli/channels", + "cli/clawbot", + "cli/completion", + "cli/config", "cli/configure", "cli/cron", + "cli/daemon", "cli/dashboard", + "cli/devices", "cli/directory", "cli/dns", "cli/docs", @@ -1201,12 +1229,15 @@ "cli/memory", "cli/message", "cli/models", + "cli/node", "cli/nodes", "cli/onboard", "cli/pairing", "cli/plugins", + "cli/qr", "cli/reset", "cli/sandbox", + "cli/secrets", "cli/security", "cli/sessions", "cli/setup", @@ -1216,7 +1247,8 @@ "cli/tui", "cli/uninstall", "cli/update", - "cli/voicecall" + "cli/voicecall", + "cli/webhooks" ] }, { @@ -1239,7 +1271,12 @@ }, { "group": "Technical reference", - "pages": ["reference/wizard", "reference/token-use", "channels/grammy"] + "pages": [ + "reference/wizard", + "reference/token-use", + "reference/prompt-caching", + "channels/grammy" + ] }, { "group": "Concept internals", @@ -1263,8 +1300,6 @@ "group": "Experiments", "pages": [ "experiments/onboarding-config-protocol", - "experiments/plans/cron-add-hardening", - "experiments/plans/group-policy-hardening", "experiments/research/memory", "experiments/proposals/model-config" ] diff --git a/docs/experiments/.DS_Store b/docs/experiments/.DS_Store deleted file mode 100644 index b13221a744b1..000000000000 Binary files a/docs/experiments/.DS_Store and /dev/null differ diff --git a/docs/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md new file mode 100644 index 000000000000..3ca509c94927 --- /dev/null +++ b/docs/experiments/plans/acp-thread-bound-agents.md @@ -0,0 +1,800 @@ +--- +summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)" +owner: "onutc" +status: "draft" +last_updated: "2026-02-25" +title: "ACP Thread Bound Agents" +--- + +# ACP Thread Bound Agents + +## Overview + +This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery. + +Related document: + +- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor) + +Target user experience: + +- a user spawns or focuses an ACP session into a thread +- user messages in that thread route to the bound ACP session +- agent output streams back to the same thread persona +- session can be persistent or one shot with explicit cleanup controls + +## Decision summary + +Long term recommendation is a hybrid architecture: + +- OpenClaw core owns ACP control plane concerns + - session identity and metadata + - thread binding and routing decisions + - delivery invariants and duplicate suppression + - lifecycle cleanup and recovery semantics +- ACP runtime backend is pluggable + - first backend is an acpx-backed plugin service + - runtime does ACP transport, queueing, cancel, reconnect + +OpenClaw should not reimplement ACP transport internals in core. +OpenClaw should not rely on a pure plugin-only interception path for routing. + +## North-star architecture (holy grail) + +Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters. + +Non-negotiable invariants: + +- every ACP thread binding references a valid ACP session record +- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`) +- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`) +- spawn, bind, and initial enqueue are atomic +- command retries are idempotent (no duplicate runs or duplicate Discord outputs) +- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects + +Long-term ownership model: + +- `AcpSessionManager` is the single ACP writer and orchestrator +- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface +- per ACP session key, manager owns one in-memory actor (serialized command execution) +- adapters (`acpx`, future backends) are transport/runtime implementations only + +Long-term persistence model: + +- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir +- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth +- store ACP events append-only to support replay, crash recovery, and deterministic delivery + +### Delivery strategy (bridge to holy-grail) + +- short-term bridge + - keep current thread binding mechanics and existing ACP config surface + - fix metadata-gap bugs and route ACP turns through a single core ACP branch + - add idempotency keys and fail-closed routing checks immediately +- long-term cutover + - move ACP source-of-truth to control-plane DB + actors + - make bound-thread delivery purely event-projection based + - remove legacy fallback behavior that depends on opportunistic session-entry metadata + +## Why not pure plugin only + +Current plugin hooks are not sufficient for end to end ACP session routing without core changes. + +- inbound routing from thread binding resolves to a session key in core dispatch first +- message hooks are fire-and-forget and cannot short-circuit the main reply path +- plugin commands are good for control operations but not for replacing core per-turn dispatch flow + +Result: + +- ACP runtime can be pluginized +- ACP routing branch must exist in core + +## Existing foundation to reuse + +Already implemented and should remain canonical: + +- thread binding target supports `subagent` and `acp` +- inbound thread routing override resolves by binding before normal dispatch +- outbound thread identity via webhook in reply delivery +- `/focus` and `/unfocus` flow with ACP target compatibility +- persistent binding store with restore on startup +- unbind lifecycle on archive, delete, unfocus, reset, and delete + +This plan extends that foundation rather than replacing it. + +## Architecture + +### Boundary model + +Core (must be in OpenClaw core): + +- ACP session-mode dispatch branch in the reply pipeline +- delivery arbitration to avoid parent plus thread duplication +- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration) +- lifecycle unbind and runtime detach semantics tied to session reset/delete + +Plugin backend (acpx implementation): + +- ACP runtime worker supervision +- acpx process invocation and event parsing +- ACP command handlers (`/acp ...`) and operator UX +- backend-specific config defaults and diagnostics + +### Runtime ownership model + +- one gateway process owns ACP orchestration state +- ACP execution runs in supervised child processes via acpx backend +- process strategy is long lived per active ACP session key, not per message + +This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable. + +### Core runtime contract + +Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic: + +```ts +export type AcpRuntimePromptMode = "prompt" | "steer"; + +export type AcpRuntimeHandle = { + sessionKey: string; + backend: string; + runtimeSessionName: string; +}; + +export type AcpRuntimeEvent = + | { type: "text_delta"; stream: "output" | "thought"; text: string } + | { type: "tool_call"; name: string; argumentsText: string } + | { type: "done"; usage?: Record } + | { type: "error"; code: string; message: string; retryable?: boolean }; + +export interface AcpRuntime { + ensureSession(input: { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + env?: Record; + idempotencyKey: string; + }): Promise; + + submit(input: { + handle: AcpRuntimeHandle; + text: string; + mode: AcpRuntimePromptMode; + idempotencyKey: string; + }): Promise<{ runtimeRunId: string }>; + + stream(input: { + handle: AcpRuntimeHandle; + runtimeRunId: string; + onEvent: (event: AcpRuntimeEvent) => Promise | void; + signal?: AbortSignal; + }): Promise; + + cancel(input: { + handle: AcpRuntimeHandle; + runtimeRunId?: string; + reason?: string; + idempotencyKey: string; + }): Promise; + + close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise; + + health?(): Promise<{ ok: boolean; details?: string }>; +} +``` + +Implementation detail: + +- first backend: `AcpxRuntime` shipped as a plugin service +- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available + +### Control-plane data model and persistence + +Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery: + +- `acp_sessions` + - `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error` +- `acp_runs` + - `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message` +- `acp_bindings` + - `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at` +- `acp_events` + - `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at` +- `acp_delivery_checkpoint` + - `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at` +- `acp_idempotency` + - `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)` + +```ts +export type AcpSessionMeta = { + backend: string; + agent: string; + runtimeSessionName: string; + mode: "persistent" | "oneshot"; + cwd?: string; + state: "idle" | "running" | "error"; + lastActivityAt: number; + lastError?: string; +}; +``` + +Storage rules: + +- keep `SessionEntry.acp` as a compatibility projection during migration +- process ids and sockets stay in memory only +- durable lifecycle and run status live in ACP DB, not generic session JSON +- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints + +### Routing and delivery + +Inbound: + +- keep current thread binding lookup as first routing step +- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig` +- explicit `/acp steer` command uses `mode: "steer"` + +Outbound: + +- ACP event stream is normalized to OpenClaw reply chunks +- delivery target is resolved through existing bound destination path +- when a bound thread is active for that session turn, parent channel completion is suppressed + +Streaming policy: + +- stream partial output with coalescing window +- configurable min interval and max chunk bytes to stay under Discord rate limits +- final message always emitted on completion or failure + +### State machines and transaction boundaries + +Session state machine: + +- `creating -> idle -> running -> idle` +- `running -> cancelling -> idle | error` +- `idle -> closed` +- `error -> idle | closed` + +Run state machine: + +- `queued -> running -> completed` +- `running -> failed | cancelled` +- `queued -> cancelled` + +Required transaction boundaries: + +- spawn transaction + - create ACP session row + - create/update ACP thread binding row + - enqueue initial run row +- close transaction + - mark session closed + - delete/expire binding rows + - write final close event +- cancel transaction + - mark target run cancelling/cancelled with idempotency key + +No partial success is allowed across these boundaries. + +### Per-session actor model + +`AcpSessionManager` runs one actor per ACP session key: + +- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects +- actor owns runtime handle hydration and runtime adapter process lifecycle for that session +- actor writes run events in-order (`seq`) before any Discord delivery +- actor updates delivery checkpoints after successful outbound send + +This removes cross-turn races and prevents duplicate or out-of-order thread output. + +### Idempotency and delivery projection + +All external ACP actions must carry idempotency keys: + +- spawn idempotency key +- prompt/steer idempotency key +- cancel idempotency key +- close idempotency key + +Delivery rules: + +- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint` +- retries resume from checkpoint without re-sending already delivered chunks +- final reply emission is exactly-once per run from projection logic + +### Recovery and self-healing + +On gateway start: + +- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`) +- recreate actors lazily on first inbound event or eagerly under configured cap +- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter + +On inbound Discord thread message: + +- if binding exists but ACP session is missing, fail closed with explicit stale-binding message +- optionally auto-unbind stale binding after operator-safe validation +- never silently route stale ACP bindings to normal LLM path + +### Lifecycle and safety + +Supported operations: + +- cancel current run: `/acp cancel` +- unbind thread: `/unfocus` +- close ACP session: `/acp close` +- auto close idle sessions by effective TTL + +TTL policy: + +- effective TTL is minimum of + - global/session TTL + - Discord thread binding TTL + - ACP runtime owner TTL + +Safety controls: + +- allowlist ACP agents by name +- restrict workspace roots for ACP sessions +- env allowlist passthrough +- max concurrent ACP sessions per account and globally +- bounded restart backoff for runtime crashes + +## Config surface + +Core keys: + +- `acp.enabled` +- `acp.dispatch.enabled` (independent ACP routing kill switch) +- `acp.backend` (default `acpx`) +- `acp.defaultAgent` +- `acp.allowedAgents[]` +- `acp.maxConcurrentSessions` +- `acp.stream.coalesceIdleMs` +- `acp.stream.maxChunkChars` +- `acp.runtime.ttlMinutes` +- `acp.controlPlane.store` (`sqlite` default) +- `acp.controlPlane.storePath` +- `acp.controlPlane.recovery.eagerActors` +- `acp.controlPlane.recovery.reconcileRunningAfterMs` +- `acp.controlPlane.checkpoint.flushEveryEvents` +- `acp.controlPlane.checkpoint.flushEveryMs` +- `acp.idempotency.ttlHours` +- `channels.discord.threadBindings.spawnAcpSessions` + +Plugin/backend keys (acpx plugin section): + +- backend command/path overrides +- backend env allowlist +- backend per-agent presets +- backend startup/stop timeouts +- backend max inflight runs per session + +## Implementation specification + +### Control-plane modules (new) + +Add dedicated ACP control-plane modules in core: + +- `src/acp/control-plane/manager.ts` + - owns ACP actors, lifecycle transitions, command serialization +- `src/acp/control-plane/store.ts` + - SQLite schema management, transactions, query helpers +- `src/acp/control-plane/events.ts` + - typed ACP event definitions and serialization +- `src/acp/control-plane/checkpoint.ts` + - durable delivery checkpoints and replay cursors +- `src/acp/control-plane/idempotency.ts` + - idempotency key reservation and response replay +- `src/acp/control-plane/recovery.ts` + - boot-time reconciliation and actor rehydrate plan + +Compatibility bridge modules: + +- `src/acp/runtime/session-meta.ts` + - remains temporarily for projection into `SessionEntry.acp` + - must stop being source-of-truth after migration cutover + +### Required invariants (must enforce in code) + +- ACP session creation and thread bind are atomic (single transaction) +- there is at most one active run per ACP session actor at a time +- event `seq` is strictly increasing per run +- delivery checkpoint never advances past last committed event +- idempotency replay returns previous success payload for duplicate command keys +- stale/missing ACP metadata cannot route into normal non-ACP reply path + +### Core touchpoints + +Core files to change: + +- `src/auto-reply/reply/dispatch-from-config.ts` + - ACP branch calls `AcpSessionManager.submit` and event-projection delivery + - remove direct ACP fallback that bypasses control-plane invariants +- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary) + - expose normalized routing keys and idempotency seeds for ACP control plane +- `src/config/sessions/types.ts` + - keep `SessionEntry.acp` as projection-only compatibility field +- `src/gateway/server-methods/sessions.ts` + - reset/delete/archive must call ACP manager close/unbind transaction path +- `src/infra/outbound/bound-delivery-router.ts` + - enforce fail-closed destination behavior for ACP bound session turns +- `src/discord/monitor/thread-bindings.ts` + - add ACP stale-binding validation helpers wired to control-plane lookups +- `src/auto-reply/reply/commands-acp.ts` + - route spawn/cancel/close/steer through ACP manager APIs +- `src/agents/acp-spawn.ts` + - stop ad-hoc metadata writes; call ACP manager spawn transaction +- `src/plugin-sdk/**` and plugin runtime bridge + - expose ACP backend registration and health semantics cleanly + +Core files explicitly not replaced: + +- `src/discord/monitor/message-handler.preflight.ts` + - keep thread binding override behavior as the canonical session-key resolver + +### ACP runtime registry API + +Add a core registry module: + +- `src/acp/runtime/registry.ts` + +Required API: + +```ts +export type AcpRuntimeBackend = { + id: string; + runtime: AcpRuntime; + healthy?: () => boolean; +}; + +export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void; +export function unregisterAcpRuntimeBackend(id: string): void; +export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null; +export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend; +``` + +Behavior: + +- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable +- plugin service registers backend on `start` and unregisters on `stop` +- runtime lookups are read-only and process-local + +### acpx runtime plugin contract (implementation detail) + +For the first production backend (`extensions/acpx`), OpenClaw and acpx are +connected with a strict command contract: + +- backend id: `acpx` +- plugin service id: `acpx-runtime` +- runtime handle encoding: `runtimeSessionName = acpx:v1:` +- encoded payload fields: + - `name` (acpx named session; uses OpenClaw `sessionKey`) + - `agent` (acpx agent command) + - `cwd` (session workspace root) + - `mode` (`persistent | oneshot`) + +Command mapping: + +- ensure session: + - `acpx --format json --json-strict --cwd sessions ensure --name ` +- prompt turn: + - `acpx --format json --json-strict --cwd prompt --session --file -` +- cancel: + - `acpx --format json --json-strict --cwd cancel --session ` +- close: + - `acpx --format json --json-strict --cwd sessions close ` + +Streaming: + +- OpenClaw consumes ndjson events from `acpx --format json --json-strict` +- `text` => `text_delta/output` +- `thought` => `text_delta/thought` +- `tool_call` => `tool_call` +- `done` => `done` +- `error` => `error` + +### Session schema patch + +Patch `SessionEntry` in `src/config/sessions/types.ts`: + +```ts +type SessionAcpMeta = { + backend: string; + agent: string; + runtimeSessionName: string; + mode: "persistent" | "oneshot"; + cwd?: string; + state: "idle" | "running" | "error"; + lastActivityAt: number; + lastError?: string; +}; +``` + +Persisted field: + +- `SessionEntry.acp?: SessionAcpMeta` + +Migration rules: + +- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth) +- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp` +- phase C: migration command backfills missing ACP rows from valid legacy entries +- phase D: remove fallback-read and keep projection optional for UX only +- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched + +### Error contract + +Add stable ACP error codes and user-facing messages: + +- `ACP_BACKEND_MISSING` + - message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.` +- `ACP_BACKEND_UNAVAILABLE` + - message: `ACP runtime backend is currently unavailable. Try again in a moment.` +- `ACP_SESSION_INIT_FAILED` + - message: `Could not initialize ACP session runtime.` +- `ACP_TURN_FAILED` + - message: `ACP turn failed before completion.` + +Rules: + +- return actionable user-safe message in-thread +- log detailed backend/system error only in runtime logs +- never silently fall back to normal LLM path when ACP routing was explicitly selected + +### Duplicate delivery arbitration + +Single routing rule for ACP bound turns: + +- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread +- do not also send to parent channel for the same turn +- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback) +- if no active binding exists, use normal session destination behavior + +### Observability and operational readiness + +Required metrics: + +- ACP spawn success/failure count by backend and error code +- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time) +- ACP actor restart count and restart reason +- stale-binding detection count +- idempotency replay hit rate +- Discord delivery retry and rate-limit counters + +Required logs: + +- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey` +- explicit state transition logs for session and run state machines +- adapter command logs with redaction-safe arguments and exit summary + +Required diagnostics: + +- `/acp sessions` includes state, active run, last error, and binding status +- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings + +### Config precedence and effective values + +ACP enablement precedence: + +- account override: `channels.discord.accounts..threadBindings.spawnAcpSessions` +- channel override: `channels.discord.threadBindings.spawnAcpSessions` +- global ACP gate: `acp.enabled` +- dispatch gate: `acp.dispatch.enabled` +- backend availability: registered backend for `acp.backend` + +Auto-enable behavior: + +- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or + `acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true` + unless denylisted or explicitly disabled + +TTL effective value: + +- `min(session ttl, discord thread binding ttl, acp runtime ttl)` + +### Test map + +Unit tests: + +- `src/acp/runtime/registry.test.ts` (new) +- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new) +- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases) +- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence) + +Integration tests: + +- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior) +- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity) +- acpx plugin runtime tests in backend package (service register/start/stop + event normalization) + +Gateway e2e tests: + +- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage) +- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery + +### Rollout guard + +Add independent ACP dispatch kill switch: + +- `acp.dispatch.enabled` default `false` for first release +- when disabled: + - ACP spawn/focus control commands may still bind sessions + - ACP dispatch path does not activate + - user receives explicit message that ACP dispatch is disabled by policy +- after canary validation, default can be flipped to `true` in a later release + +## Command and UX plan + +### New commands + +- `/acp spawn [--mode persistent|oneshot] [--thread auto|here|off]` +- `/acp cancel [session]` +- `/acp steer ` +- `/acp close [session]` +- `/acp sessions` + +### Existing command compatibility + +- `/focus ` continues to support ACP targets +- `/unfocus` keeps current semantics +- `/session ttl` remains the top level TTL override + +## Phased rollout + +### Phase 0 ADR and schema freeze + +- ship ADR for ACP control-plane ownership and adapter boundaries +- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`) +- define stable ACP error codes, event contract, and state-transition guards + +### Phase 1 Control-plane foundation in core + +- implement `AcpSessionManager` and per-session actor runtime +- implement ACP SQLite store and transaction helpers +- implement idempotency store and replay helpers +- implement event append + delivery checkpoint modules +- wire spawn/cancel/close APIs to manager with transactional guarantees + +### Phase 2 Core routing and lifecycle integration + +- route thread-bound ACP turns from dispatch pipeline into ACP manager +- enforce fail-closed routing when ACP binding/session invariants fail +- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions +- add stale-binding detection and optional auto-unbind policy + +### Phase 3 acpx backend adapter/plugin + +- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`) +- add backend health checks and startup/teardown registration +- normalize acpx ndjson events into ACP runtime events +- enforce backend timeouts, process supervision, and restart/backoff policy + +### Phase 4 Delivery projection and channel UX (Discord first) + +- implement event-driven channel projection with checkpoint resume (Discord first) +- coalesce streaming chunks with rate-limit aware flush policy +- guarantee exactly-once final completion message per run +- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions` + +### Phase 5 Migration and cutover + +- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth +- add migration utility for legacy ACP metadata rows +- flip read path to ACP SQLite primary +- remove legacy fallback routing that depends on missing `SessionEntry.acp` + +### Phase 6 Hardening, SLOs, and scale limits + +- enforce concurrency limits (global/account/session), queue policies, and timeout budgets +- add full telemetry, dashboards, and alert thresholds +- chaos-test crash recovery and duplicate-delivery suppression +- publish runbook for backend outage, DB corruption, and stale-binding remediation + +### Full implementation checklist + +- core control-plane modules and tests +- DB migrations and rollback plan +- ACP manager API integration across dispatch and commands +- adapter registration interface in plugin runtime bridge +- acpx adapter implementation and tests +- thread-capable channel delivery projection logic with checkpoint replay (Discord first) +- lifecycle hooks for reset/delete/archive/unfocus +- stale-binding detector and operator-facing diagnostics +- config validation and precedence tests for all new ACP keys +- operational docs and troubleshooting runbook + +## Test plan + +Unit tests: + +- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close) +- ACP state-machine transition guards for sessions and runs +- idempotency reservation/replay semantics across all ACP commands +- per-session actor serialization and queue ordering +- acpx event parser and chunk coalescer +- runtime supervisor restart and backoff policy +- config precedence and effective TTL calculation +- core ACP routing branch selection and fail-closed behavior when backend/session is invalid + +Integration tests: + +- fake ACP adapter process for deterministic streaming and cancel behavior +- ACP manager + dispatch integration with transactional persistence +- thread-bound inbound routing to ACP session key +- thread-bound outbound delivery suppresses parent channel duplication +- checkpoint replay recovers after delivery failure and resumes from last event +- plugin service registration and teardown of ACP runtime backend + +Gateway e2e tests: + +- spawn ACP with thread, exchange multi-turn prompts, unfocus +- gateway restart with persisted ACP DB and bindings, then continue same session +- concurrent ACP sessions in multiple threads have no cross-talk +- duplicate command retries (same idempotency key) do not create duplicate runs or replies +- stale-binding scenario yields explicit error and optional auto-clean behavior + +## Risks and mitigations + +- Duplicate deliveries during transition + - Mitigation: single destination resolver and idempotent event checkpoint +- Runtime process churn under load + - Mitigation: long lived per session owners + concurrency caps + backoff +- Plugin absent or misconfigured + - Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path) +- Config confusion between subagent and ACP gates + - Mitigation: explicit ACP keys and command feedback that includes effective policy source +- Control-plane store corruption or migration bugs + - Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics +- Actor deadlocks or mailbox starvation + - Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry + +## Acceptance checklist + +- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord) +- all thread messages route to bound ACP session only +- ACP outputs appear in the same thread identity with streaming or batches +- no duplicate output in parent channel for bound turns +- spawn+bind+initial enqueue are atomic in persistent store +- ACP command retries are idempotent and do not duplicate runs or outputs +- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup +- crash restart preserves mapping and resumes multi turn continuity +- concurrent thread bound ACP sessions work independently +- ACP backend missing state produces clear actionable error +- stale bindings are detected and surfaced explicitly (with optional safe auto-clean) +- control-plane metrics and diagnostics are available for operators +- new unit, integration, and e2e coverage passes + +## Addendum: targeted refactors for current implementation (status) + +These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands. + +### 1) Centralize ACP dispatch policy evaluation (completed) + +- implemented via shared ACP policy helpers in `src/acp/policy.ts` +- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic + +### 2) Split ACP command handler by subcommand domain (completed) + +- `src/auto-reply/reply/commands-acp.ts` is now a thin router +- subcommand behavior is split into: + - `src/auto-reply/reply/commands-acp/lifecycle.ts` + - `src/auto-reply/reply/commands-acp/runtime-options.ts` + - `src/auto-reply/reply/commands-acp/diagnostics.ts` + - shared helpers in `src/auto-reply/reply/commands-acp/shared.ts` + +### 3) Split ACP session manager by responsibility (completed) + +- manager is split into: + - `src/acp/control-plane/manager.ts` (public facade + singleton) + - `src/acp/control-plane/manager.core.ts` (manager implementation) + - `src/acp/control-plane/manager.types.ts` (manager types/deps) + - `src/acp/control-plane/manager.utils.ts` (normalization + helper functions) + +### 4) Optional acpx runtime adapter cleanup + +- `extensions/acpx/src/runtime.ts` can be split into: +- process execution/supervision +- ndjson event parsing/normalization +- runtime API surface (`submit`, `cancel`, `close`, etc.) +- improves testability and makes backend behavior easier to audit diff --git a/docs/experiments/plans/acp-unified-streaming-refactor.md b/docs/experiments/plans/acp-unified-streaming-refactor.md new file mode 100644 index 000000000000..3834fb9f8d89 --- /dev/null +++ b/docs/experiments/plans/acp-unified-streaming-refactor.md @@ -0,0 +1,96 @@ +--- +summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP" +owner: "onutc" +status: "draft" +last_updated: "2026-02-25" +title: "Unified Runtime Streaming Refactor Plan" +--- + +# Unified Runtime Streaming Refactor Plan + +## Objective + +Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior. + +## Why this exists + +- Current behavior is split across multiple runtime-specific shaping paths. +- Formatting/coalescing bugs can be fixed in one path but remain in others. +- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about. + +## Target architecture + +Single pipeline, runtime-specific adapters: + +1. Runtime adapters emit canonical events only. +2. Shared stream assembler coalesces and finalizes text/tool/status events. +3. Shared channel projector applies channel-specific chunking/formatting once. +4. Shared delivery ledger enforces idempotent send/replay semantics. +5. Outbound channel adapter executes sends and records delivery checkpoints. + +Canonical event contract: + +- `turn_started` +- `text_delta` +- `block_final` +- `tool_started` +- `tool_finished` +- `status` +- `turn_completed` +- `turn_failed` +- `turn_cancelled` + +## Workstreams + +### 1) Canonical streaming contract + +- Define strict event schema + validation in core. +- Add adapter contract tests to guarantee each runtime emits compatible events. +- Reject malformed runtime events early and surface structured diagnostics. + +### 2) Shared stream processor + +- Replace runtime-specific coalescer/projector logic with one processor. +- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush. +- Move ACP/main/subagent config resolution into one helper to prevent drift. + +### 3) Shared channel projection + +- Keep channel adapters dumb: accept finalized blocks and send. +- Move Discord-specific chunking quirks to channel projector only. +- Keep pipeline channel-agnostic before projection. + +### 4) Delivery ledger + replay + +- Add per-turn/per-chunk delivery IDs. +- Record checkpoints before and after physical send. +- On restart, replay pending chunks idempotently and avoid duplicates. + +### 5) Migration and cutover + +- Phase 1: shadow mode (new pipeline computes output but old path sends; compare). +- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk). +- Phase 3: delete legacy runtime-specific streaming code. + +## Non-goals + +- No changes to ACP policy/permissions model in this refactor. +- No channel-specific feature expansion outside projection compatibility fixes. +- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity). + +## Risks and mitigations + +- Risk: behavioral regressions in existing main/subagent paths. + Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests. +- Risk: duplicate sends during crash recovery. + Mitigation: durable delivery IDs + idempotent replay in delivery adapter. +- Risk: runtime adapters diverge again. + Mitigation: required shared contract test suite for all adapters. + +## Acceptance criteria + +- All runtimes pass shared streaming contract tests. +- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas. +- Crash/restart replay sends no duplicate chunk for the same delivery ID. +- Legacy ACP projector/coalescer path is removed. +- Streaming config resolution is shared and runtime-independent. diff --git a/docs/experiments/plans/browser-evaluate-cdp-refactor.md b/docs/experiments/plans/browser-evaluate-cdp-refactor.md index 553437d62eec..5832c8a65e66 100644 --- a/docs/experiments/plans/browser-evaluate-cdp-refactor.md +++ b/docs/experiments/plans/browser-evaluate-cdp-refactor.md @@ -1,5 +1,8 @@ --- summary: "Plan: isolate browser act:evaluate from Playwright queue using CDP, with end-to-end deadlines and safer ref resolution" +read_when: + - Working on browser `act:evaluate` timeout, abort, or queue blocking issues + - Planning CDP based isolation for evaluate execution owner: "openclaw" status: "draft" last_updated: "2026-02-10" diff --git a/docs/experiments/plans/cron-add-hardening.md b/docs/experiments/plans/cron-add-hardening.md deleted file mode 100644 index 0ef55fda173b..000000000000 --- a/docs/experiments/plans/cron-add-hardening.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -summary: "Harden cron.add input handling, align schemas, and improve cron UI/agent tooling" -owner: "openclaw" -status: "complete" -last_updated: "2026-01-05" -title: "Cron Add Hardening" ---- - -# Cron Add Hardening & Schema Alignment - -## Context - -Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron provider enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`). - -## Goals - -- Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields. -- Align cron provider lists across gateway schema, cron types, CLI docs, and UI forms. -- Make agent cron tool schema explicit so the LLM produces correct job payloads. -- Fix the Control UI cron status job count display. -- Add tests to cover normalization and tool behavior. - -## Non-goals - -- Change cron scheduling semantics or job execution behavior. -- Add new schedule kinds or cron expression parsing. -- Overhaul the UI/UX for cron beyond the necessary field fixes. - -## Findings (current gaps) - -- `CronPayloadSchema` in gateway excludes `signal` + `imessage`, while TS types include them. -- Control UI CronStatus expects `jobCount`, but gateway returns `jobs`. -- Agent cron tool schema allows arbitrary `job` objects, enabling malformed inputs. -- Gateway strictly validates `cron.add` with no normalization, so wrapped payloads fail. - -## What changed - -- `cron.add` and `cron.update` now normalize common wrapper shapes and infer missing `kind` fields. -- Agent cron tool schema matches the gateway schema, which reduces invalid payloads. -- Provider enums are aligned across gateway, CLI, UI, and macOS picker. -- Control UI uses the gateway’s `jobs` count field for status. - -## Current behavior - -- **Normalization:** wrapped `data`/`job` payloads are unwrapped; `schedule.kind` and `payload.kind` are inferred when safe. -- **Defaults:** safe defaults are applied for `wakeMode` and `sessionTarget` when missing. -- **Providers:** Discord/Slack/Signal/iMessage are now consistently surfaced across CLI/UI. - -See [Cron jobs](/automation/cron-jobs) for the normalized shape and examples. - -## Verification - -- Watch gateway logs for reduced `cron.add` INVALID_REQUEST errors. -- Confirm Control UI cron status shows job count after refresh. - -## Optional Follow-ups - -- Manual Control UI smoke: add a cron job per provider + verify status job count. - -## Open Questions - -- Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)? -- Should we allow `webchat` as an explicit delivery provider (currently filtered in delivery resolution)? diff --git a/docs/experiments/plans/group-policy-hardening.md b/docs/experiments/plans/group-policy-hardening.md deleted file mode 100644 index 2a51b7c130b7..000000000000 --- a/docs/experiments/plans/group-policy-hardening.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -summary: "Telegram allowlist hardening: prefix + whitespace normalization" -read_when: - - Reviewing historical Telegram allowlist changes -title: "Telegram Allowlist Hardening" ---- - -# Telegram Allowlist Hardening - -**Date**: 2026-01-05 -**Status**: Complete -**PR**: #216 - -## Summary - -Telegram allowlists now accept `telegram:` and `tg:` prefixes case-insensitively, and tolerate -accidental whitespace. This aligns inbound allowlist checks with outbound send normalization. - -## What changed - -- Prefixes `telegram:` and `tg:` are treated the same (case-insensitive). -- Allowlist entries are trimmed; empty entries are ignored. - -## Examples - -All of these are accepted for the same ID: - -- `telegram:123456` -- `TG:123456` -- `tg:123456` - -## Why it matters - -Copy/paste from logs or chat IDs often includes prefixes and whitespace. Normalizing avoids -false negatives when deciding whether to respond in DMs or groups. - -## Related docs - -- [Group Chats](/channels/groups) -- [Telegram Provider](/channels/telegram) diff --git a/docs/experiments/plans/openresponses-gateway.md b/docs/experiments/plans/openresponses-gateway.md index 4133940bdb4e..8ca63c34ec9b 100644 --- a/docs/experiments/plans/openresponses-gateway.md +++ b/docs/experiments/plans/openresponses-gateway.md @@ -1,5 +1,8 @@ --- summary: "Plan: Add OpenResponses /v1/responses endpoint and deprecate chat completions cleanly" +read_when: + - Designing or implementing `/v1/responses` gateway support + - Planning migration from Chat Completions compatibility owner: "openclaw" status: "draft" last_updated: "2026-01-19" @@ -113,7 +116,7 @@ Key points extracted: - Non-stream response shape - Stream event ordering and `[DONE]` - Session routing with headers and `user` -- Keep `src/gateway/openai-http.e2e.test.ts` unchanged. +- Keep `src/gateway/openai-http.test.ts` unchanged. - Manual: curl to `/v1/responses` with `stream: true` and verify event ordering and terminal `[DONE]`. diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md index 352850c82f6c..4ec898058cd7 100644 --- a/docs/experiments/plans/pty-process-supervision.md +++ b/docs/experiments/plans/pty-process-supervision.md @@ -1,5 +1,8 @@ --- summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup" +read_when: + - Working on exec/process lifecycle ownership and cleanup + - Debugging PTY and non-PTY supervision behavior owner: "openclaw" status: "in-progress" last_updated: "2026-02-15" @@ -156,12 +159,12 @@ Unit tests: E2E targets: -- `pnpm test:e2e src/agents/cli-runner.e2e.test.ts` -- `pnpm test:e2e src/agents/bash-tools.exec.pty-fallback.e2e.test.ts src/agents/bash-tools.exec.background-abort.e2e.test.ts src/agents/bash-tools.process.send-keys.e2e.test.ts` +- `pnpm vitest src/agents/cli-runner.test.ts` +- `pnpm vitest run src/agents/bash-tools.exec.pty-fallback.test.ts src/agents/bash-tools.exec.background-abort.test.ts src/agents/bash-tools.process.send-keys.test.ts` Typecheck note: -- `pnpm tsgo` currently fails in this repo due to a pre-existing UI typing dependency issue (`@vitest/browser-playwright` resolution), unrelated to this process supervision work. +- Use `pnpm build` (and `pnpm check` for full lint/docs gate) in this repo. Older notes that mention `pnpm tsgo` are obsolete. ## 8. Operational guarantees preserved diff --git a/docs/experiments/plans/session-binding-channel-agnostic.md b/docs/experiments/plans/session-binding-channel-agnostic.md index c66b6e8193e8..aa1f926b36b1 100644 --- a/docs/experiments/plans/session-binding-channel-agnostic.md +++ b/docs/experiments/plans/session-binding-channel-agnostic.md @@ -1,5 +1,8 @@ --- summary: "Channel agnostic session binding architecture and iteration 1 delivery scope" +read_when: + - Refactoring channel-agnostic session routing and bindings + - Investigating duplicate, stale, or missing session delivery across channels owner: "onutc" status: "in-progress" last_updated: "2026-02-21" @@ -212,7 +215,7 @@ Tests: - `src/discord/monitor/provider*.test.ts` - `src/discord/monitor/reply-delivery.test.ts` -- `src/agents/subagent-announce.format.e2e.test.ts` +- `src/agents/subagent-announce.format.test.ts` ## Done criteria for iteration 1 diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 8dd18f8416d9..448789c9a6cd 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -14,6 +14,7 @@ use the long‑lived token created by `claude setup-token`. See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage layout. +For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets). ## Recommended Anthropic setup (API key) @@ -85,6 +86,11 @@ openclaw models auth paste-token --provider anthropic openclaw models auth paste-token --provider openrouter ``` +Auth profile refs are also supported for static credentials: + +- `api_key` credentials can use `keyRef: { source, provider, id }` +- `token` credentials can use `tokenRef: { source, provider, id }` + Automation-friendly check (exit `1` when expired/missing, `2` when expiring): ```bash diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 960f37c005bc..0639dc36e926 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -169,6 +169,9 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional + highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes) }, typingIntervalSeconds: 5, sendPolicy: { @@ -199,7 +202,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["steipete"] }, + dm: { enabled: true, allowFrom: ["123456789012345678"] }, guilds: { "123456789012345678": { slug: "friends-of-openclaw", @@ -270,6 +273,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. every: "30m", model: "anthropic/claude-sonnet-4-5", target: "last", + directPolicy: "allow", // allow (default) | block to: "+15555550123", prompt: "HEARTBEAT", ackMaxChars: 300, @@ -314,7 +318,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. allowFrom: { whatsapp: ["+15555550123"], telegram: ["123456789"], - discord: ["steipete"], + discord: ["123456789012345678"], slack: ["U123"], signal: ["+15555550123"], imessage: ["user@example.com"], @@ -355,6 +359,10 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. store: "~/.openclaw/cron/cron.json", maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, // Webhooks @@ -454,7 +462,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_TOKEN", - dm: { allowFrom: ["yourname"] }, + dm: { allowFrom: ["123456789012345678"] }, }, }, } @@ -480,12 +488,15 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["alice", "bob"] }, + dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] }, }, }, } ``` +For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default. +Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk. + ### OAuth with API key failover ```json5 @@ -617,4 +628,4 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin - If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`. - Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format. - Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`. -- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes. +- See [Providers](/providers) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b11ea7a37aae..67c325ff5c47 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1,6 +1,10 @@ --- title: "Configuration Reference" description: "Complete field-by-field reference for ~/.openclaw/openclaw.json" +summary: "Complete reference for every OpenClaw config key, defaults, and channel settings" +read_when: + - You need exact field-level config semantics or defaults + - You are validating channel, model, gateway, or tool config blocks --- # Configuration Reference @@ -35,7 +39,7 @@ All channels support DM policies and group policies: `channels.defaults.groupPolicy` sets the default when a provider's `groupPolicy` is unset. Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3 per channel**. -Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning). +If a provider block is missing entirely (`channels.` absent), runtime group policy falls back to `allowlist` (fail-closed) with a startup warning. ### Channel model overrides @@ -161,7 +165,10 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat maxDelayMs: 30000, jitter: 0.1, }, - network: { autoSelectFamily: false }, + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", webhookSecret: "secret", @@ -205,7 +212,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat }, replyToMode: "off", // off | first | all dmPolicy: "pairing", - allowFrom: ["1234567890", "steipete"], + allowFrom: ["1234567890", "123456789012345678"], dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] }, guilds: { "123456789012345678": { @@ -248,6 +255,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -275,7 +284,10 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. +- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default). +- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. +- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages). @@ -309,8 +321,10 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`). +- Service account SecretRef is also supported (`serviceAccountRef`). - Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. -- Use `spaces/` or `users/` for delivery targets. +- Use `spaces/` or `users/` for delivery targets. +- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode). ### Slack @@ -492,6 +506,9 @@ Run multiple accounts per channel (each with its own `accountId`): - Env tokens only apply to the **default** account. - Base channel settings apply to all accounts unless overridden per account. - Use `bindings[].match.accountId` to route each account to a different agent. +- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw moves account-scoped top-level single-account values into `channels..accounts.default` first so the original account keeps working. +- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional. +- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing. ### Group chat mention gating @@ -711,9 +728,16 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` +- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - String form sets only the primary model. + - Object form sets primary plus ordered failover models. +- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - Used by the `image` tool path as its vision-model config. + - Also used as fallback routing when the selected/default model cannot accept image input. - `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated). -- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific: `temperature`, `maxTokens`). -- `imageModel`: only used if the primary model lacks image input. +- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`). +- `params` merge precedence (config): `agents.defaults.models["provider/model"].params` is the base, then `agents.list[].params` (matching agent id) overrides by key. +- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): @@ -780,7 +804,8 @@ Periodic heartbeat runs. includeReasoning: false, session: "main", to: "+15555550123", - target: "last", // last | whatsapp | telegram | discord | ... | none + directPolicy: "allow", // allow (default) | block + target: "none", // default: none | options: last | whatsapp | telegram | discord | ... prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, suppressToolErrorWarnings: false, @@ -792,6 +817,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. +- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. @@ -1001,7 +1027,9 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. -**Containers default to `network: "none"`** — set to `"bridge"` if the agent needs outbound access. +**Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access. +`"host"` is blocked. `"container:"` is blocked by default unless you explicitly set +`sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). **Inbound attachments** are staged into `media/inbound/*` in the active workspace. @@ -1037,6 +1065,7 @@ scripts/sandbox-browser-setup.sh # optional browser image workspace: "~/.openclaw/workspace", agentDir: "~/.openclaw/agents/main/agent", model: "anthropic/claude-opus-4-6", // or { primary, fallbacks } + params: { cacheRetention: "none" }, // overrides matching defaults.models params by key identity: { name: "Samantha", theme: "helpful sloth", @@ -1061,6 +1090,7 @@ scripts/sandbox-browser-setup.sh # optional browser image - `id`: stable agent id (required). - `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default. - `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. +- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. - `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only). @@ -1225,11 +1255,15 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden }, resetTriggers: ["/new", "/reset"], store: "~/.openclaw/agents/{agentId}/sessions/sessions.json", + parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables) maintenance: { mode: "warn", // warn | enforce pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional hard budget + highWaterBytes: "400mb", // optional cleanup target }, threadBindings: { enabled: true, @@ -1255,9 +1289,19 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. - **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. +- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`). + - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history. + - Set `0` to disable this guard and always allow parent forking. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. -- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation. +- **`maintenance`**: session-store cleanup + retention controls. + - `mode`: `warn` emits warnings only; `enforce` applies cleanup. + - `pruneAfter`: age cutoff for stale entries (default `30d`). + - `maxEntries`: maximum number of entries in `sessions.json` (default `500`). + - `rotateBytes`: rotate `sessions.json` when it exceeds this size (default `10mb`). + - `resetArchiveRetention`: retention for `*.reset.` transcript archives. Defaults to `pruneAfter`; set `false` to disable. + - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. + - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. - **`threadBindings`**: global defaults for thread-bound session features. - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) - `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override) @@ -1464,7 +1508,7 @@ Controls elevated (host) exec access: enabled: true, allowFrom: { whatsapp: ["+15555550123"], - discord: ["steipete", "1234567890123"], + discord: ["1234567890123", "987654321098765432"], }, }, }, @@ -1655,6 +1699,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1663,6 +1708,7 @@ Notes: ``` - `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model. +- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout. - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`. --- @@ -1699,6 +1745,10 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - Use `authHeader: true` + `headers` for custom auth needs. - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`). +- Merge precedence for matching provider IDs: + - Non-empty agent `models.json` `apiKey`/`baseUrl` win. + - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. + - Use `models.mode: "replace"` when you want config to fully rewrite `models.json`. ### Provider examples @@ -1937,7 +1987,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio }, entries: { "nano-banana-pro": { - apiKey: "GEMINI_KEY_HERE", + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" }, }, peekaboo: { enabled: true }, @@ -1949,7 +1999,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio - `allowBundled`: optional allowlist for bundled skills only (managed/workspace skills unaffected). - `entries..enabled: false` disables a skill even if bundled/installed. -- `entries..apiKey`: convenience for skills declaring a primary env var. +- `entries..apiKey`: convenience for skills declaring a primary env var (plaintext string or SecretRef object). --- @@ -1990,6 +2040,12 @@ See [Plugins](/tools/plugin). enabled: true, evaluateEnabled: true, defaultProfile: "chrome", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, @@ -2005,6 +2061,10 @@ See [Plugins](/tools/plugin). ``` - `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`. +- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model). +- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation. +- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. +- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). @@ -2059,6 +2119,8 @@ See [Plugins](/tools/plugin). enabled: true, basePath: "/openclaw", // root: "dist/control-ui", + // allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI + // dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode // allowInsecureAuth: false, // dangerouslyDisableDeviceAuth: false, }, @@ -2092,9 +2154,13 @@ See [Plugins](/tools/plugin). - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). +- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. +- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. -- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. +- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. +- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). @@ -2110,6 +2176,8 @@ See [Plugins](/tools/plugin). - `gateway.http.endpoints.responses.maxUrlParts` - `gateway.http.endpoints.responses.files.urlAllowlist` - `gateway.http.endpoints.responses.images.urlAllowlist` +- Optional response hardening header: + - `gateway.http.securityHeaders.strictTransportSecurity` (set only for HTTPS origins you control; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts)) ### Multi-instance isolation @@ -2318,6 +2386,73 @@ Reference env vars in any config string with `${VAR_NAME}`: --- +## Secrets + +Secret refs are additive: plaintext values still work. + +### `SecretRef` + +Use one object shape: + +```json5 +{ source: "env" | "file" | "exec", provider: "default", id: "..." } +``` + +Validation: + +- `provider` pattern: `^[a-z][a-z0-9_-]{0,63}$` +- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$` +- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`) +- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` + +### Supported fields in config + +- `models.providers..apiKey` +- `skills.entries..apiKey` +- `channels.googlechat.serviceAccount` +- `channels.googlechat.serviceAccountRef` +- `channels.googlechat.accounts..serviceAccount` +- `channels.googlechat.accounts..serviceAccountRef` + +### Secret providers config + +```json5 +{ + secrets: { + providers: { + default: { source: "env" }, // optional explicit env provider + filemain: { + source: "file", + path: "~/.openclaw/secrets.json", + mode: "json", + timeoutMs: 5000, + }, + vault: { + source: "exec", + command: "/usr/local/bin/openclaw-vault-resolver", + passEnv: ["PATH", "VAULT_ADDR"], + }, + }, + defaults: { + env: "default", + file: "filemain", + exec: "vault", + }, + }, +} +``` + +Notes: + +- `file` provider supports `mode: "json"` and `mode: "singleValue"` (`id` must be `"value"` in singleValue mode). +- `exec` provider requires an absolute `command` path and uses protocol payloads on stdin/stdout. +- By default, symlink command paths are rejected. Set `allowSymlinkCommand: true` to allow symlink paths while validating the resolved target path. +- If `trustedDirs` is configured, the trusted-dir check applies to the resolved target path. +- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`. +- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only. + +--- + ## Auth storage ```json5 @@ -2335,8 +2470,11 @@ Reference env vars in any config string with `${VAR_NAME}`: ``` - Per-agent auth profiles stored at `/auth-profiles.json`. +- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`). +- Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered. - Legacy OAuth imports from `~/.openclaw/credentials/oauth.json`. - See [OAuth](/concepts/oauth). +- Secrets runtime behavior and `audit/configure/apply` tooling: [Secrets Management](/gateway/secrets). --- @@ -2441,11 +2579,17 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` -- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable. +- `runLog.maxBytes`: max size per run log file (`cron/runs/.jsonl`) before pruning. Default: `2_000_000` bytes. +- `runLog.keepLines`: newest lines retained when run-log pruning is triggered. Default: `2000`. - `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. - `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e367b4caf0d4..46756dbc01a9 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -240,6 +240,7 @@ When validation fails: - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets - See [Heartbeat](/gateway/heartbeat) for the full guide. @@ -251,11 +252,17 @@ When validation fails: enabled: true, maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, } ``` - See [Cron jobs](/automation/cron-jobs) for the feature overview and CLI examples. + - `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable). + - `runLog`: prune `cron/runs/.jsonl` by size and retained lines. + - See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples. @@ -485,6 +492,42 @@ Rules: + + For fields that support SecretRef objects, you can use: + +```json5 +{ + models: { + providers: { + openai: { apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" } }, + }, + }, + skills: { + entries: { + "nano-banana-pro": { + apiKey: { + source: "file", + provider: "filemain", + id: "/skills/entries/nano-banana-pro/apiKey", + }, + }, + }, + }, + channels: { + googlechat: { + serviceAccountRef: { + source: "exec", + provider: "vault", + id: "channels/googlechat/serviceAccount", + }, + }, + }, +} +``` + +SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets). + + See [Environment](/help/environment) for full precedence and sources. ## Full reference diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index f048435483aa..4ecc10b4c66f 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -121,10 +121,12 @@ Current migrations: - `routing.agentToAgent` → `tools.agentToAgent` - `routing.transcribeAudio` → `tools.media.audio.models` - `bindings[].match.accountID` → `bindings[].match.accountId` +- For channels with named `accounts` but missing `accounts.default`, move account-scoped top-level single-account channel values into `channels..accounts.default` when present - `identity` → `agents.list[].identity` - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` +- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` ### 2b) OpenCode Zen provider overrides diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b682da0f814d..a4f4aa64ea94 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -19,7 +19,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) 1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence. 2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). -3. Decide where heartbeat messages should go (`target: "last"` is the default). +3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact). 4. Optional: enable heartbeat reasoning delivery for transparency. 5. Optional: restrict heartbeats to active hours (local time). @@ -31,7 +31,8 @@ Example config: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") + directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress // activeHours: { start: "08:00", end: "24:00" }, // includeReasoning: true, // optional: send separate `Reasoning:` message too }, @@ -87,7 +88,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-6", includeReasoning: false, // default: false (deliver separate Reasoning: message when available) - target: "last", // last | none | (core or plugin, e.g. "bluebubbles") + target: "last", // default: none | options: last | none | (core or plugin, e.g. "bluebubbles") to: "+15551234567", // optional channel-specific override accountId: "ops-bot", // optional multi-account channel id prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", @@ -120,7 +121,7 @@ Example: two agents, only the second agent runs heartbeats. defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") }, }, list: [ @@ -149,7 +150,7 @@ Restrict heartbeats to business hours in a specific timezone: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") activeHours: { start: "09:00", end: "22:00", @@ -212,9 +213,12 @@ Use `accountId` to target a specific account on multi-account channels like Tele - Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)). - Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups). - `target`: - - `last` (default): deliver to the last used external channel. + - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - - `none`: run the heartbeat but **do not deliver** externally. + - `none` (default): run the heartbeat but **do not deliver** externally. +- `directPolicy`: controls direct/DM delivery behavior: + - `allow` (default): allow direct/DM heartbeat delivery. + - `block`: suppress direct/DM delivery (`reason=dm-blocked`). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -235,6 +239,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. +- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index c1e06d634577..f64de55f32a5 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -16,6 +16,12 @@ Use this page for day-1 startup and day-2 operations of the Gateway service. Task-oriented setup guide + full configuration reference. + + SecretRef contract, runtime snapshot behavior, and migrate/reload operations. + + + Exact `secrets apply` target/path rules and ref-only auth-profile behavior. + ## 5-minute local startup @@ -94,6 +100,7 @@ openclaw gateway status --json openclaw gateway install openclaw gateway restart openclaw gateway stop +openclaw secrets reload openclaw logs --follow openclaw doctor ``` diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index fde213bb1f76..e80263ab443d 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -170,6 +170,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. +### Operator helper methods + +- Operators may call `tools.catalog` (`operator.read`) to fetch the runtime tool catalog for an + agent. The response includes grouped tools and provenance metadata: + - `source`: `core` or `plugin` + - `pluginId`: plugin owner when `source="plugin"` + - `optional`: whether a plugin tool is optional + ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. @@ -206,7 +214,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - All WS clients must include `device` identity during `connect` (operator + node). Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth` is enabled for break-glass use. -- Non-local connections must sign the server-provided `connect.challenge` nonce. +- All connections must sign the server-provided `connect.challenge` nonce. +- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily` + in addition to device/client/role/scopes/token/nonce fields. +- Legacy `v2` signatures remain accepted for compatibility, but paired-device + metadata pinning still controls command policy on reconnect. ## TLS + pinning diff --git a/docs/gateway/remote-gateway-readme.md b/docs/gateway/remote-gateway-readme.md index 27fbfb6d2a9a..cb069629070e 100644 --- a/docs/gateway/remote-gateway-readme.md +++ b/docs/gateway/remote-gateway-readme.md @@ -84,7 +84,7 @@ To have the SSH tunnel start automatically when you log in, create a Launch Agen ### Create the PLIST file -Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: +Save this as `~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist`: ```xml @@ -92,7 +92,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: Label - bot.molt.ssh-tunnel + ai.openclaw.ssh-tunnel ProgramArguments /usr/bin/ssh @@ -110,7 +110,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: ### Load the Launch Agent ```bash -launchctl bootstrap gui/$UID ~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist ``` The tunnel will now: @@ -135,13 +135,13 @@ lsof -i :18789 **Restart the tunnel:** ```bash -launchctl kickstart -k gui/$UID/bot.molt.ssh-tunnel +launchctl kickstart -k gui/$UID/ai.openclaw.ssh-tunnel ``` **Stop the tunnel:** ```bash -launchctl bootout gui/$UID/bot.molt.ssh-tunnel +launchctl bootout gui/$UID/ai.openclaw.ssh-tunnel ``` --- diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 6eedfc3b35d4..68170fe2b888 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -101,6 +101,20 @@ You can persist a remote target so CLI commands use it by default: When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first. +## Credential precedence + +Gateway call/probe credential resolution now follows one shared contract: + +- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win. +- Local mode defaults: + - token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` + - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` +- Remote mode defaults: + - token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` + - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password` +- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode. +- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only. + ## Chat UI over SSH WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects directly to the Gateway WebSocket. @@ -120,7 +134,8 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need - **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure). - **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords. -- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth. +- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves. +- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. - **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 6d51f573990a..8be57bd10642 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -138,6 +138,12 @@ scripts/sandbox-browser-setup.sh By default, sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. +Security defaults: + +- `network: "host"` is blocked. +- `network: "container:"` is blocked by default (namespace join bypass risk). +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. + Docker installs and the containerized gateway live here: [Docker](/install/docker) @@ -154,6 +160,7 @@ Paths: Common pitfalls: - Default `docker.network` is `"none"` (no egress), so package installs will fail. +- `docker.network: "container:"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only. - `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. - `user` must be root for package installs (omit `user` or set `user: "0:0"`). - Sandbox exec does **not** inherit host `process.env`. Use diff --git a/docs/gateway/secrets-plan-contract.md b/docs/gateway/secrets-plan-contract.md new file mode 100644 index 000000000000..d503d6cac82b --- /dev/null +++ b/docs/gateway/secrets-plan-contract.md @@ -0,0 +1,94 @@ +--- +summary: "Contract for `secrets apply` plans: allowed target paths, validation, and ref-only auth-profile behavior" +read_when: + - Generating or reviewing `openclaw secrets apply` plan files + - Debugging `Invalid plan target path` errors + - Understanding how `keyRef` and `tokenRef` influence implicit provider discovery +title: "Secrets Apply Plan Contract" +--- + +# Secrets apply plan contract + +This page defines the strict contract enforced by `openclaw secrets apply`. + +If a target does not match these rules, apply fails before mutating config. + +## Plan file shape + +`openclaw secrets apply --from ` expects a `targets` array of plan targets: + +```json5 +{ + version: 1, + protocolVersion: 1, + targets: [ + { + type: "models.providers.apiKey", + path: "models.providers.openai.apiKey", + pathSegments: ["models", "providers", "openai", "apiKey"], + providerId: "openai", + ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + ], +} +``` + +## Allowed target types and paths + +| `target.type` | Allowed `target.path` shape | Optional id match rule | +| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- | +| `models.providers.apiKey` | `models.providers..apiKey` | `providerId` must match `` when present | +| `skills.entries.apiKey` | `skills.entries..apiKey` | n/a | +| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted | +| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts..serviceAccount` | `accountId` must match `` when present | + +## Path validation rules + +Each target is validated with all of the following: + +- `type` must be one of the allowed target types above. +- `path` must be a non-empty dot path. +- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`. +- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`. +- The normalized path must match one of the allowed path shapes for the target type. +- If `providerId` / `accountId` is set, it must match the id encoded in the path. + +## Failure behavior + +If a target fails validation, apply exits with an error like: + +```text +Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl +``` + +No partial mutation is committed for that invalid target path. + +## Ref-only auth profiles and implicit providers + +Implicit provider discovery also considers auth profiles that store refs instead of plaintext credentials: + +- `type: "api_key"` profiles can use `keyRef` (for example env-backed refs). +- `type: "token"` profiles can use `tokenRef`. + +Behavior: + +- For API-key providers (for example `volcengine`, `byteplus`), ref-only profiles can still activate implicit provider entries. +- For `github-copilot`, if the profile has no plaintext token, discovery will try `tokenRef` env resolution before token exchange. + +## Operator checks + +```bash +# Validate plan without writes +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run + +# Then apply for real +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json +``` + +If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to one of the allowed shapes above. + +## Related docs + +- [Secrets Management](/gateway/secrets) +- [CLI `secrets`](/cli/secrets) +- [Configuration Reference](/gateway/configuration-reference) diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md new file mode 100644 index 000000000000..9fdec280d611 --- /dev/null +++ b/docs/gateway/secrets.md @@ -0,0 +1,386 @@ +--- +summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing" +read_when: + - Configuring SecretRefs for providers, auth profiles, skills, or Google Chat + - Operating secrets reload/audit/configure/apply safely in production + - Understanding fail-fast and last-known-good behavior +title: "Secrets Management" +--- + +# Secrets management + +OpenClaw supports additive secret references so credentials do not need to be stored as plaintext in config files. + +Plaintext still works. Secret refs are optional. + +## Goals and runtime model + +Secrets are resolved into an in-memory runtime snapshot. + +- Resolution is eager during activation, not lazy on request paths. +- Startup fails fast if any referenced credential cannot be resolved. +- Reload uses atomic swap: full success or keep last-known-good. +- Runtime requests read from the active in-memory snapshot. + +This keeps secret-provider outages off the hot request path. + +## Onboarding reference preflight + +When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving: + +- Env refs: validates env var name and confirms a non-empty value is visible during onboarding. +- Provider refs (`file` or `exec`): validates the selected provider, resolves the provided `id`, and checks value type. + +If validation fails, onboarding shows the error and lets you retry. + +## SecretRef contract + +Use one object shape everywhere: + +```json5 +{ source: "env" | "file" | "exec", provider: "default", id: "..." } +``` + +### `source: "env"` + +```json5 +{ source: "env", provider: "default", id: "OPENAI_API_KEY" } +``` + +Validation: + +- `provider` must match `^[a-z][a-z0-9_-]{0,63}$` +- `id` must match `^[A-Z][A-Z0-9_]{0,127}$` + +### `source: "file"` + +```json5 +{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" } +``` + +Validation: + +- `provider` must match `^[a-z][a-z0-9_-]{0,63}$` +- `id` must be an absolute JSON pointer (`/...`) +- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1` + +### `source: "exec"` + +```json5 +{ source: "exec", provider: "vault", id: "providers/openai/apiKey" } +``` + +Validation: + +- `provider` must match `^[a-z][a-z0-9_-]{0,63}$` +- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` + +## Provider config + +Define providers under `secrets.providers`: + +```json5 +{ + secrets: { + providers: { + default: { source: "env" }, + filemain: { + source: "file", + path: "~/.openclaw/secrets.json", + mode: "json", // or "singleValue" + }, + vault: { + source: "exec", + command: "/usr/local/bin/openclaw-vault-resolver", + args: ["--profile", "prod"], + passEnv: ["PATH", "VAULT_ADDR"], + jsonOnly: true, + }, + }, + defaults: { + env: "default", + file: "filemain", + exec: "vault", + }, + resolution: { + maxProviderConcurrency: 4, + maxRefsPerProvider: 512, + maxBatchBytes: 262144, + }, + }, +} +``` + +### Env provider + +- Optional allowlist via `allowlist`. +- Missing/empty env values fail resolution. + +### File provider + +- Reads local file from `path`. +- `mode: "json"` expects JSON object payload and resolves `id` as pointer. +- `mode: "singleValue"` expects ref id `"value"` and returns file contents. +- Path must pass ownership/permission checks. + +### Exec provider + +- Runs configured absolute binary path, no shell. +- By default, `command` must point to a regular file (not a symlink). +- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path. +- Enable `allowSymlinkCommand` only when required for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`). +- When `trustedDirs` is set, checks apply to the resolved target path. +- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs. +- Request payload (stdin): + +```json +{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] } +``` + +- Response payload (stdout): + +```json +{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } } +``` + +Optional per-id errors: + +```json +{ + "protocolVersion": 1, + "values": {}, + "errors": { "providers/openai/apiKey": { "message": "not found" } } +} +``` + +## Exec integration examples + +### 1Password CLI + +```json5 +{ + secrets: { + providers: { + onepassword_openai: { + source: "exec", + command: "/opt/homebrew/bin/op", + allowSymlinkCommand: true, // required for Homebrew symlinked binaries + trustedDirs: ["/opt/homebrew"], + args: ["read", "op://Personal/OpenClaw QA API Key/password"], + passEnv: ["HOME"], + jsonOnly: false, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5", name: "gpt-5" }], + apiKey: { source: "exec", provider: "onepassword_openai", id: "value" }, + }, + }, + }, +} +``` + +### HashiCorp Vault CLI + +```json5 +{ + secrets: { + providers: { + vault_openai: { + source: "exec", + command: "/opt/homebrew/bin/vault", + allowSymlinkCommand: true, // required for Homebrew symlinked binaries + trustedDirs: ["/opt/homebrew"], + args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"], + passEnv: ["VAULT_ADDR", "VAULT_TOKEN"], + jsonOnly: false, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5", name: "gpt-5" }], + apiKey: { source: "exec", provider: "vault_openai", id: "value" }, + }, + }, + }, +} +``` + +### `sops` + +```json5 +{ + secrets: { + providers: { + sops_openai: { + source: "exec", + command: "/opt/homebrew/bin/sops", + allowSymlinkCommand: true, // required for Homebrew symlinked binaries + trustedDirs: ["/opt/homebrew"], + args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"], + passEnv: ["SOPS_AGE_KEY_FILE"], + jsonOnly: false, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5", name: "gpt-5" }], + apiKey: { source: "exec", provider: "sops_openai", id: "value" }, + }, + }, + }, +} +``` + +## In-scope fields (v1) + +### `~/.openclaw/openclaw.json` + +- `models.providers..apiKey` +- `skills.entries..apiKey` +- `channels.googlechat.serviceAccount` +- `channels.googlechat.serviceAccountRef` +- `channels.googlechat.accounts..serviceAccount` +- `channels.googlechat.accounts..serviceAccountRef` + +### `~/.openclaw/agents//agent/auth-profiles.json` + +- `profiles..keyRef` for `type: "api_key"` +- `profiles..tokenRef` for `type: "token"` + +OAuth credential storage changes are out of scope. + +## Required behavior and precedence + +- Field without ref: unchanged. +- Field with ref: required at activation time. +- If plaintext and ref both exist, ref wins at runtime and plaintext is ignored. + +Warning code: + +- `SECRETS_REF_OVERRIDES_PLAINTEXT` + +## Activation triggers + +Secret activation is attempted on: + +- Startup (preflight plus final activation) +- Config reload hot-apply path +- Config reload restart-check path +- Manual reload via `secrets.reload` + +Activation contract: + +- Success swaps the snapshot atomically. +- Startup failure aborts gateway startup. +- Runtime reload failure keeps last-known-good snapshot. + +## Degraded and recovered operator signals + +When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state. + +One-shot system event and log codes: + +- `SECRETS_RELOADER_DEGRADED` +- `SECRETS_RELOADER_RECOVERED` + +Behavior: + +- Degraded: runtime keeps last-known-good snapshot. +- Recovered: emitted once after a successful activation. +- Repeated failures while already degraded log warnings but do not spam events. +- Startup fail-fast does not emit degraded events because no runtime snapshot exists yet. + +## Audit and configure workflow + +Use this default operator flow: + +```bash +openclaw secrets audit --check +openclaw secrets configure +openclaw secrets audit --check +``` + +Migration completeness: + +- Include `skills.entries..apiKey` targets when those skills use API keys. +- If `audit --check` still reports plaintext findings after a partial migration, migrate the remaining reported paths and rerun audit. + +### `secrets audit` + +Findings include: + +- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`) +- unresolved refs +- precedence shadowing (`auth-profiles` taking priority over config refs) +- legacy residues (`auth.json`, OAuth out-of-scope reminders) + +### `secrets configure` + +Interactive helper that: + +- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove) +- lets you select secret-bearing fields in `openclaw.json` +- captures SecretRef details (`source`, `provider`, `id`) +- runs preflight resolution +- can apply immediately + +Helpful modes: + +- `openclaw secrets configure --providers-only` +- `openclaw secrets configure --skip-provider-setup` + +`configure` apply defaults to: + +- scrub matching static creds from `auth-profiles.json` for targeted providers +- scrub legacy static `api_key` entries from `auth.json` +- scrub matching known secret lines from `/.env` + +### `secrets apply` + +Apply a saved plan: + +```bash +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json +openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run +``` + +For strict target/path contract details and exact rejection rules, see: + +- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) + +## One-way safety policy + +OpenClaw intentionally does **not** write rollback backups that contain pre-migration plaintext secret values. + +Safety model: + +- preflight must succeed before write mode +- runtime activation is validated before commit +- apply updates files using atomic file replacement and best-effort in-memory restore on failure + +## `auth.json` compatibility notes + +For static credentials, OpenClaw runtime no longer depends on plaintext `auth.json`. + +- Runtime credential source is the resolved in-memory snapshot. +- Legacy `auth.json` static `api_key` entries are scrubbed when discovered. +- OAuth-related legacy compatibility behavior remains separate. + +## Related docs + +- CLI commands: [secrets](/cli/secrets) +- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) +- Auth setup: [Authentication](/gateway/authentication) +- Security posture: [Security](/gateway/security) +- Environment precedence: [Environment Variables](/help/environment) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 188573ba6501..55e2a0767667 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -7,6 +7,22 @@ title: "Security" # Security 🔒 +> [!WARNING] +> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). +> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway. +> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts). + +## Scope first: personal assistant security model + +OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents. + +- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary). +- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users. +- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts). +- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent. + +This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway. + ## Quick check: `openclaw security audit` See also: [Formal Verification (Security Models)](/security/formal-verification/) @@ -37,6 +53,94 @@ OpenClaw assumes the host and config boundary are trusted: - If someone can modify Gateway host state/config (`~/.openclaw`, including `openclaw.json`), treat them as a trusted operator. - Running one Gateway for multiple mutually untrusted/adversarial operators is **not a recommended setup**. - For mixed-trust teams, split trust boundaries with separate gateways (or at minimum separate OS users/hosts). +- OpenClaw can run multiple gateway instances on one machine, but recommended operations favor clean trust-boundary separation. +- Recommended default: one user per machine/host (or VPS), one gateway for that user, and one or more agents in that gateway. +- If multiple users want OpenClaw, use one VPS/host per user. + +### Practical consequence (operator trust boundary) + +Inside one Gateway instance, authenticated operator access is a trusted control-plane role, not a per-user tenant role. + +- Operators with read/control-plane access can inspect gateway session metadata/history by design. +- Session identifiers (`sessionKey`, session IDs, labels) are routing selectors, not authorization tokens. +- Example: expecting per-operator isolation for methods like `sessions.list`, `sessions.preview`, or `chat.history` is outside this model. +- If you need adversarial-user isolation, run separate gateways per trust boundary. +- Multiple gateways on one machine are technically possible, but not the recommended baseline for multi-user isolation. + +## Personal assistant model (not a multi-tenant bus) + +OpenClaw is designed as a personal assistant security model: one trusted operator boundary, potentially many agents. + +- If several people can message one tool-enabled agent, each of them can steer that same permission set. +- Per-user session/memory isolation helps privacy, but does not convert a shared agent into per-user host authorization. +- If users may be adversarial to each other, run separate gateways (or separate OS users/hosts) per trust boundary. + +### Shared Slack workspace: real risk + +If "everyone in Slack can message the bot," the core risk is delegated tool authority: + +- any allowed sender can induce tool calls (`exec`, browser, network/file tools) within the agent's policy; +- prompt/content injection from one sender can cause actions that affect shared state, devices, or outputs; +- if one shared agent has sensitive credentials/files, any allowed sender can potentially drive exfiltration via tool usage. + +Use separate agents/gateways with minimal tools for team workflows; keep personal-data agents private. + +### Company-shared agent: acceptable pattern + +This is acceptable when everyone using that agent is in the same trust boundary (for example one company team) and the agent is strictly business-scoped. + +- run it on a dedicated machine/VM/container; +- use a dedicated OS user + dedicated browser/profile/accounts for that runtime; +- do not sign that runtime into personal Apple/Google accounts or personal password-manager/browser profiles. + +If you mix personal and company identities on the same runtime, you collapse the separation and increase personal-data exposure risk. + +## Gateway and node trust concept + +Treat Gateway and node as one operator trust domain, with different roles: + +- **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing). +- **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities). +- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node. +- `sessionKey` is routing/context selection, not per-user auth. +- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation. + +If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways. + +## Trust boundary matrix + +Use this as the quick model when triaging risk: + +| Boundary or control | What it means | Common misread | +| ------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| `gateway.auth` (token/password/device auth) | Authenticates callers to gateway APIs | "Needs per-message signatures on every frame to be secure" | +| `sessionKey` | Routing key for context/session selection | "Session key is a user auth boundary" | +| Prompt/content guardrails | Reduce model abuse risk | "Prompt injection alone proves auth bypass" | +| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" | +| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | +| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | + +## Not vulnerabilities by design + +These patterns are commonly reported and are usually closed as no-action unless a real boundary bypass is shown: + +- Prompt-injection-only chains without a policy/auth/sandbox bypass. +- Claims that assume hostile multi-tenant operation on one shared host/config. +- Claims that classify normal operator read-path access (for example `sessions.list`/`sessions.preview`/`chat.history`) as IDOR in a shared-gateway setup. +- Localhost-only deployment findings (for example HSTS on loopback-only gateway). +- Discord inbound webhook signature findings for inbound paths that do not exist in this repo. +- "Missing per-user authorization" findings that treat `sessionKey` as an auth token. + +## Researcher preflight checklist + +Before opening a GHSA, verify all of these: + +1. Repro still works on latest `main` or latest release. +2. Report includes exact code path (`file`, function, line range) and tested version/commit. +3. Impact crosses a documented trust boundary (not just prompt injection). +4. Claim is not listed in [Out of Scope](https://github.com/openclaw/openclaw/blob/main/SECURITY.md#out-of-scope). +5. Existing advisories were checked for duplicates (reuse canonical GHSA when applicable). +6. Deployment assumptions are explicit (loopback/local vs exposed, trusted vs untrusted operators). ## Hardened baseline in 60 seconds @@ -84,7 +188,7 @@ If more than one person can DM your bot: - **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints). - **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths). - **Plugins** (extensions exist without an explicit allowlist). -- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy). +- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns because matching is exact command-name only (for example `system.run`) and does not inspect shell text; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy). - **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host). - **Model hygiene** (warn when configured models look legacy; not a hard block). @@ -98,8 +202,11 @@ Use this when auditing access or deciding what to back up: - **Telegram bot token**: config/env or `channels.telegram.tokenFile` - **Discord bot token**: config/env (token file not yet supported) - **Slack tokens**: config/env (`channels.slack.*`) -- **Pairing allowlists**: `~/.openclaw/credentials/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//agent/auth-profiles.json` +- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json` - **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json` ## Security Audit Checklist @@ -117,29 +224,39 @@ When the audit prints findings, treat this as a priority order: High-signal `checkId` values you will most likely see in real deployments (not exhaustive): -| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | -| --------------------------------------------- | ------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | -------- | -| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | -| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | -| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | -| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | -| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | -| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no | -| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | -| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | -| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | -| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | -| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | -| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | -| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | -| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | -| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | -| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | -| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | -| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | -| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | +| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | +| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- | +| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | +| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | +| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | +| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | +| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | +| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no | +| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | +| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | +| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | +| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | +| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | +| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | +| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | +| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | +| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no | +| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | +| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | +| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | +| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no | +| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | +| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | +| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no | +| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no | +| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | +| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | +| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | ## Control UI over HTTP @@ -156,13 +273,40 @@ keep it off unless you are actively debugging and can revert quickly. ## Insecure or dangerous flags summary -`openclaw security audit` includes `config.insecure_or_dangerous_flags` when any -insecure/dangerous debug switches are enabled. This warning aggregates the exact -keys so you can review them in one place (for example -`gateway.controlUi.allowInsecureAuth=true`, -`gateway.controlUi.dangerouslyDisableDeviceAuth=true`, -`hooks.gmail.allowUnsafeExternalContent=true`, or -`tools.exec.applyPatch.workspaceOnly=false`). +`openclaw security audit` includes `config.insecure_or_dangerous_flags` when +known insecure/dangerous debug switches are enabled. That check currently +aggregates: + +- `gateway.controlUi.allowInsecureAuth=true` +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` +- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` +- `hooks.gmail.allowUnsafeExternalContent=true` +- `hooks.mappings[].allowUnsafeExternalContent=true` +- `tools.exec.applyPatch.workspaceOnly=false` + +Complete `dangerous*` / `dangerously*` config keys defined in OpenClaw config +schema: + +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` +- `gateway.controlUi.dangerouslyDisableDeviceAuth` +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +- `channels.discord.dangerouslyAllowNameMatching` +- `channels.discord.accounts..dangerouslyAllowNameMatching` +- `channels.slack.dangerouslyAllowNameMatching` +- `channels.slack.accounts..dangerouslyAllowNameMatching` +- `channels.googlechat.dangerouslyAllowNameMatching` +- `channels.googlechat.accounts..dangerouslyAllowNameMatching` +- `channels.msteams.dangerouslyAllowNameMatching` +- `channels.irc.dangerouslyAllowNameMatching` (extension channel) +- `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.accounts..dangerouslyAllowNameMatching` (extension channel) +- `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin` +- `agents.list[].sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.list[].sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin` ## Reverse Proxy Configuration @@ -197,6 +341,15 @@ Bad reverse proxy behavior (append/preserve untrusted forwarding headers): proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` +## HSTS and origin notes + +- OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there. +- If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. +- Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). +- For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. +- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. + ## Local session logs live on disk OpenClaw stores session transcripts on disk under `~/.openclaw/agents//sessions/*.jsonl`. @@ -328,6 +481,7 @@ This is a messaging-context boundary, not a host-admin boundary. If users are mu Treat the snippet above as **secure DM mode**: - Default: `session.dmScope: "main"` (all DMs share one session for continuity). +- Local CLI onboarding default: writes `session.dmScope: "per-channel-peer"` when unset (keeps existing explicit values). - Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context). If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). @@ -337,7 +491,7 @@ If you run multiple accounts on the same channel, use `per-account-channel-peer` OpenClaw has two separate “who can trigger me?” layers: - **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages. - - When `dmPolicy="pairing"`, approvals are written to `~/.openclaw/credentials/-allowFrom.json` (merged with config allowlists). + - When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`-allowFrom.json` for default account, `--allowFrom.json` for non-default accounts), merged with config allowlists. - **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all. - Common patterns: - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). @@ -532,8 +686,10 @@ Set a token so **all** WS clients must authenticate: Doctor can generate one for you: `openclaw doctor --generate-gateway-token`. -Note: `gateway.remote.token` is **only** for remote CLI calls; it does not -protect local WS access. +Note: `gateway.remote.token` / `.password` are client credential sources. They +do **not** protect local WS access by themselves. +Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` +is unset. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Local device pairing: @@ -607,7 +763,9 @@ Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain sec - `openclaw.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists. - `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports. -- `agents//agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`). +- `agents//agent/auth-profiles.json`: API keys, token profiles, OAuth tokens, and optional `keyRef`/`tokenRef`. +- `secrets.json` (optional): file-backed secret payload used by `file` SecretRef providers (`secrets.providers`). +- `agents//agent/auth.json`: legacy compatibility file. Static `api_key` entries are scrubbed when discovered. - `agents//sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output. - `extensions/**`: installed plugins (plus their `node_modules/`). - `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox. @@ -685,7 +843,8 @@ We may add a single `readOnlyMode` flag later to simplify this configuration. Additional hardening options: - `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- Keep filesystem roots narrow: avoid broad roots like your home directory for agent workspaces/sandbox workspaces. Broad roots can expose sensitive local files (for example state/config under `~/.openclaw`) to filesystem tools. ### 5) Secure baseline (copy/paste) @@ -750,6 +909,30 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +### Browser SSRF policy (trusted-network default) + +OpenClaw’s browser network policy defaults to the trusted-operator model: private/internal destinations are allowed unless you explicitly disable them. + +- Default: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: true` (implicit when unset). +- Legacy alias: `browser.ssrfPolicy.allowPrivateNetwork` is still accepted for compatibility. +- Strict mode: set `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: false` to block private/internal/special-use destinations by default. +- In strict mode, use `hostnameAllowlist` (patterns like `*.example.com`) and `allowedHostnames` (exact host exceptions, including blocked names like `localhost`) for explicit exceptions. +- Navigation is checked before request and best-effort re-checked on the final `http(s)` URL after navigation to reduce redirect-based pivots. + +Example strict policy: + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], + }, + }, +} +``` + ## Per-agent access profiles (multi-agent) With multi-agent routing, each agent can have its own sandbox + tool policy: @@ -881,7 +1064,7 @@ If your AI does something bad: 1. Rotate Gateway auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_PASSWORD`) and restart. 2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway. -3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`). +3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`, and encrypted secrets payload values when used). ### Audit diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index d3bb0ad9e410..45963f155798 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -36,7 +36,7 @@ If channels are up but nothing answers, check routing and policy before reconnec ```bash openclaw status openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw config get channels openclaw logs --follow ``` @@ -125,7 +125,7 @@ If channel state is connected but message flow is dead, focus on policy, permiss ```bash openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw status --deep openclaw logs --follow openclaw config get channels @@ -174,6 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`. Related: @@ -289,7 +290,7 @@ Common signatures: ```bash openclaw devices list -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw logs --follow openclaw doctor ``` diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index f9debcfaef02..7144452b2e6c 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -4,6 +4,7 @@ read_when: - Running OpenClaw behind an identity-aware proxy - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw - Fixing WebSocket 1008 unauthorized errors with reverse proxy setups + - Deciding where to set HSTS and other HTTP hardening headers --- # Trusted Proxy Auth @@ -34,6 +35,18 @@ Use `trusted-proxy` auth mode when: 4. OpenClaw extracts the user identity from the configured header 5. If everything checks out, the request is authorized +## Control UI Pairing Behavior + +When `gateway.auth.mode = "trusted-proxy"` is active and the request passes +trusted-proxy checks, Control UI WebSocket sessions can connect without device +pairing identity. + +Implications: + +- Pairing is no longer the primary gate for Control UI access in this mode. +- Your reverse proxy auth policy and `allowUsers` become the effective access control. +- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall). + ## Configuration ```json5 @@ -75,6 +88,52 @@ If `gateway.bind` is `loopback`, include a loopback proxy address in | `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | | `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | +## TLS termination and HSTS + +Use one TLS termination point and apply HSTS there. + +### Recommended pattern: proxy TLS termination + +When your reverse proxy handles HTTPS for `https://control.example.com`, set +`Strict-Transport-Security` at the proxy for that domain. + +- Good fit for internet-facing deployments. +- Keeps certificate + HTTP hardening policy in one place. +- OpenClaw can stay on loopback HTTP behind the proxy. + +Example header value: + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +### Gateway TLS termination + +If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: + +```json5 +{ + gateway: { + tls: { enabled: true }, + http: { + securityHeaders: { + strictTransportSecurity: "max-age=31536000; includeSubDomains", + }, + }, + }, +} +``` + +`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + +### Rollout guidance + +- Start with a short max age first (for example `max-age=300`) while validating traffic. +- Increase to long-lived values (for example `max-age=31536000`) only after confidence is high. +- Add `includeSubDomains` only if every subdomain is HTTPS-ready. +- Use preload only if you intentionally meet preload requirements for your full domain set. +- Loopback-only local development does not benefit from HSTS. + ## Proxy Setup Examples ### Pomerium diff --git a/docs/help/environment.md b/docs/help/environment.md index 4ad054ebf73c..d261faeaa078 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -74,6 +74,15 @@ You can reference env vars directly in config string values using `${VAR_NAME}` See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details. +## Secret refs vs `${ENV}` strings + +OpenClaw supports two env-driven patterns: + +- `${VAR}` string substitution in config values. +- SecretRef objects (`{ source: "env", provider: "default", id: "VAR" }`) for fields that support secrets references. + +Both resolve from process env at activation time. SecretRef details are documented in [Secrets Management](/gateway/secrets). + ## Path-related env vars | Variable | Purpose | @@ -82,6 +91,12 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit | `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). | | `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). | +## Logging + +| Variable | Purpose | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENCLAW_LOG_LEVEL` | Override log level for both file and console (e.g. `debug`, `trace`). Takes precedence over `logging.level` and `logging.consoleLevel` in config. Invalid values are ignored with a warning. | + ### `OPENCLAW_HOME` When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for all internal path resolution. This enables full filesystem isolation for headless service accounts. diff --git a/docs/help/faq.md b/docs/help/faq.md index e60329e86c63..cd12c790f539 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1,5 +1,8 @@ --- summary: "Frequently asked questions about OpenClaw setup, configuration, and usage" +read_when: + - Answering common setup, install, onboarding, or runtime support questions + - Triaging user-reported issues before deeper debugging title: "FAQ" --- @@ -1248,14 +1251,15 @@ still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`) If you don't set a provider explicitly, OpenClaw auto-selects a provider when it can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars). It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key -resolves. If neither key is available, memory search stays disabled until you -configure it. If you have a local model path configured and present, OpenClaw +resolves, then Voyage, then Mistral. If no remote key is available, memory +search stays disabled until you configure it. If you have a local model path +configured and present, OpenClaw prefers `local`. If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally `memorySearch.fallback = "none"`). If you want Gemini embeddings, set `memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or -`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding +`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding models - see [Memory](/concepts/memory) for the setup details. ### Does memory persist forever What are the limits @@ -1287,16 +1291,17 @@ Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory Everything lives under `$OPENCLAW_STATE_DIR` (default: `~/.openclaw`): -| Path | Purpose | -| --------------------------------------------------------------- | ------------------------------------------------------------ | -| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) | -| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) | -| `$OPENCLAW_STATE_DIR/agents//agent/auth-profiles.json` | Auth profiles (OAuth + API keys) | -| `$OPENCLAW_STATE_DIR/agents//agent/auth.json` | Runtime auth cache (managed automatically) | -| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp//creds.json`) | -| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) | -| `$OPENCLAW_STATE_DIR/agents//sessions/` | Conversation history & state (per agent) | -| `$OPENCLAW_STATE_DIR/agents//sessions/sessions.json` | Session metadata (per agent) | +| Path | Purpose | +| --------------------------------------------------------------- | ------------------------------------------------------------------ | +| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) | +| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) | +| `$OPENCLAW_STATE_DIR/agents//agent/auth-profiles.json` | Auth profiles (OAuth, API keys, and optional `keyRef`/`tokenRef`) | +| `$OPENCLAW_STATE_DIR/secrets.json` | Optional file-backed secret payload for `file` SecretRef providers | +| `$OPENCLAW_STATE_DIR/agents//agent/auth.json` | Legacy compatibility file (static `api_key` entries scrubbed) | +| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp//creds.json`) | +| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) | +| `$OPENCLAW_STATE_DIR/agents//sessions/` | Conversation history & state (per agent) | +| `$OPENCLAW_STATE_DIR/agents//sessions/sessions.json` | Session metadata (per agent) | Legacy single-agent path: `~/.openclaw/agent/*` (migrated by `openclaw doctor`). @@ -1334,7 +1339,7 @@ Put your **agent workspace** in a **private** git repo and back it up somewhere private (for example GitHub private). This captures memory + AGENTS/SOUL/USER files, and lets you restore the assistant's "mind" later. -Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens). +Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens, or encrypted secrets payloads). If you need a full restore, back up both the workspace and the state directory separately (see the migration question above). @@ -1400,7 +1405,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au Notes: -- `gateway.remote.token` is for **remote CLI calls** only; it does not enable local gateway auth. +- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves. +- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. - The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs. ### Why do I need a token on localhost now @@ -2471,7 +2477,7 @@ Quick setup (recommended): - Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). - Install a per-profile service: `openclaw --profile gateway install`. -Profiles also suffix service names (`bot.molt.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). +Profiles also suffix service names (`ai.openclaw.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). Full guide: [Multiple gateways](/gateway/multiple-gateways). ### What does invalid handshake code 1008 mean @@ -2701,8 +2707,8 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk: - Default behavior on DM-capable channels is **pairing**: - Unknown senders receive a pairing code; the bot does not process their message. - - Approve with: `openclaw pairing approve ` - - Pending requests are capped at **3 per channel**; check `openclaw pairing list ` if a code didn't arrive. + - Approve with: `openclaw pairing approve --channel [--account ] ` + - Pending requests are capped at **3 per channel**; check `openclaw pairing list --channel [--account ]` if a code didn't arrive. - Opening DMs publicly requires explicit opt-in (`dmPolicy: "open"` and allowlist `"*"`). Run `openclaw doctor` to surface risky DM policies. @@ -2810,6 +2816,19 @@ Send any of these **as a standalone message** (no slash): ``` stop +stop action +stop current action +stop run +stop current run +stop agent +stop the agent +stop openclaw +openclaw stop +stop don't do anything +stop do not do anything +stop doing anything +please stop +stop please abort esc wait diff --git a/docs/help/testing.md b/docs/help/testing.md index 62cfda47a222..01bb80abb473 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -336,6 +336,11 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con - Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`) - Plugins (custom extension load + registry smoke): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) +Manual ACP plain-language thread smoke (not CI): + +- `bun scripts/dev/discord-acp-plain-language-smoke.ts --channel ...` +- Keep this script for regression/debug workflows. It may be needed again for ACP thread routing validation, so do not delete it. + Useful env vars: - `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw` @@ -352,15 +357,15 @@ Run docs checks after doc edits: `pnpm docs:list`. These are “real pipeline” regressions without real providers: -- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.tool-calling.mock-openai.test.ts` -- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.wizard.e2e.test.ts` +- Gateway tool calling (mock OpenAI, real gateway + agent loop): `src/gateway/gateway.test.ts` (case: "runs a mock OpenAI tool call end-to-end via gateway agent loop") +- Gateway wizard (WS `wizard.start`/`wizard.next`, writes config + auth enforced): `src/gateway/gateway.test.ts` (case: "runs wizard over ws and writes auth token config") ## Agent reliability evals (skills) We already have a few CI-safe tests that behave like “agent reliability evals”: -- Mock tool-calling through the real gateway + agent loop (`src/gateway/gateway.tool-calling.mock-openai.test.ts`). -- End-to-end wizard flows that validate session wiring and config effects (`src/gateway/gateway.wizard.e2e.test.ts`). +- Mock tool-calling through the real gateway + agent loop (`src/gateway/gateway.test.ts`). +- End-to-end wizard flows that validate session wiring and config effects (`src/gateway/gateway.test.ts`). What’s still missing for skills (see [Skills](/tools/skills)): diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 83cad80ba32a..c4754da18673 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -62,7 +62,7 @@ flowchart TD openclaw status openclaw gateway status openclaw channels status --probe - openclaw pairing list + openclaw pairing list --channel [--account ] openclaw logs --follow ``` diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index c31ec7c06187..a585ce9f2a9c 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -60,7 +60,9 @@ When you switch channels with `openclaw update`, OpenClaw also syncs plugin sour ## Tagging best practices -- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-`). +- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta). +- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`. +- Legacy `vYYYY.M.D-` tags are still recognized as stable (non-beta). - Keep tags immutable: never move or reuse a tag. - npm dist-tags remain the source of truth for npm installs: - `latest` → stable diff --git a/docs/install/docker.md b/docs/install/docker.md index 8826192c1c13..42cefd4be01b 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -26,6 +26,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing) ## Requirements - Docker Desktop (or Docker Engine) + Docker Compose v2 +- At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137) - Enough disk for images + logs ## Containerized Gateway (Docker Compose) @@ -368,6 +369,8 @@ precedence, and troubleshooting. - `"rw"` mounts the agent workspace read/write at `/workspace` - Auto-prune: idle > 24h OR age > 7d - Network: `none` by default (explicitly opt-in if you need egress) + - `host` is blocked. + - `container:` is blocked by default (namespace-join risk). - Default allow: `exec`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway` @@ -376,6 +379,9 @@ precedence, and troubleshooting. If you plan to install packages in `setupCommand`, note: - Default `docker.network` is `"none"` (no egress). +- `docker.network: "host"` is blocked. +- `docker.network: "container:"` is blocked by default. +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. - `readOnlyRoot: true` blocks package installs. - `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`). OpenClaw auto-recreates containers when `setupCommand` (or docker config) changes @@ -445,7 +451,8 @@ If you plan to install packages in `setupCommand`, note: Hardening knobs live under `agents.defaults.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, -`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. +`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`, +`dangerouslyAllowContainerNamespaceJoin` (break-glass only). Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*` (ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`). diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index 687233b11401..c49dab4e4268 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -31,7 +31,7 @@ Shelley, [exe.dev](https://exe.dev)'s agent, can install OpenClaw instantly with prompt. The prompt used is as below: ``` -Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw device approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. +Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. ``` ## Manual installation diff --git a/docs/install/fly.md b/docs/install/fly.md index 0e0745c12607..3b2ad9d92051 100644 --- a/docs/install/fly.md +++ b/docs/install/fly.md @@ -1,6 +1,10 @@ --- title: Fly.io description: Deploy OpenClaw on Fly.io +summary: "Step-by-step Fly.io deployment for OpenClaw with persistent storage and HTTPS" +read_when: + - Deploying OpenClaw on Fly.io + - Setting up Fly volumes, secrets, and first-run config --- # Fly.io Deployment diff --git a/docs/install/gcp.md b/docs/install/gcp.md index b0ec51a75dd0..2c6bdd8ac1f6 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -114,10 +114,11 @@ gcloud services enable compute.googleapis.com **Machine types:** -| Type | Specs | Cost | Notes | -| -------- | ------------------------ | ------------------ | ------------------ | -| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended | -| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load | +| Type | Specs | Cost | Notes | +| --------- | ------------------------ | ------------------ | -------------------------------------------- | +| e2-medium | 2 vCPU, 4GB RAM | ~$25/mo | Most reliable for local Docker builds | +| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Minimum recommended for Docker build | +| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | Often fails with Docker build OOM (exit 137) | **CLI:** @@ -350,6 +351,16 @@ docker compose build docker compose up -d openclaw-gateway ``` +If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. + +When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing: + +```bash +docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json +``` + +If you changed the gateway port, replace `18789` with your configured port. + Verify binaries: ```bash @@ -394,7 +405,20 @@ Open in your browser: `http://127.0.0.1:18789/` -Paste your gateway token. +Fetch a fresh tokenized dashboard link: + +```bash +docker compose run --rm openclaw-cli dashboard --no-open +``` + +Paste the token from that URL. + +If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device: + +```bash +docker compose run --rm openclaw-cli devices list +docker compose run --rm openclaw-cli devices approve +``` --- @@ -449,7 +473,7 @@ Ensure your account has the required IAM permissions (Compute OS Login or Comput **Out of memory (OOM)** -If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium: +If Docker build fails with `Killed` and `exit code 137`, the VM was OOM-killed. Upgrade to e2-small (minimum) or e2-medium (recommended for reliable local builds): ```bash # Stop the VM first diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md index 7ca46ff7cd9a..9baf90278b87 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -17,6 +17,14 @@ Run a persistent OpenClaw Gateway on a Hetzner VPS using Docker, with durable st If you want “OpenClaw 24/7 for ~$5”, this is the simplest reliable setup. Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS and scale up if you hit OOMs. +Security model reminder: + +- Company-shared agents are fine when everyone is in the same trust boundary and the runtime is business-only. +- Keep strict separation: dedicated VPS/runtime + dedicated accounts; no personal Apple/Google/browser/password-manager profiles on that host. +- If users are adversarial to each other, split by gateway/host/OS user. + +See [Security](/gateway/security) and [VPS hosting](/vps). + ## What are we doing (simple terms)? - Rent a small Linux server (Hetzner VPS) diff --git a/docs/install/nix.md b/docs/install/nix.md index a17e46589a7b..784ca24707aa 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -58,7 +58,7 @@ On macOS, the GUI app does not automatically inherit shell env vars. You can also enable Nix mode via defaults: ```bash -defaults write bot.molt.mac openclaw.nixMode -bool true +defaults write ai.openclaw.mac openclaw.nixMode -bool true ``` ### Config + state paths diff --git a/docs/install/podman.md b/docs/install/podman.md index 3b56c9ce25e7..707fdd3a106e 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -85,6 +85,7 @@ To add quadlet **after** an initial setup that did not use it, re-run: `./setup- - **Token:** Stored in `~openclaw/.openclaw/.env` as `OPENCLAW_GATEWAY_TOKEN`. `setup-podman.sh` and `run-openclaw-podman.sh` generate it if missing (uses `openssl`, `python3`, or `od`). - **Optional:** In that `.env` you can set provider keys (e.g. `GROQ_API_KEY`, `OLLAMA_API_KEY`) and other OpenClaw env vars. - **Host ports:** By default the script maps `18789` (gateway) and `18790` (bridge). Override the **host** port mapping with `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` and `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` when launching. +- **Gateway bind:** By default, `run-openclaw-podman.sh` starts the gateway with `--bind loopback` for safe local access. To expose on LAN, set `OPENCLAW_GATEWAY_BIND=lan` and configure `gateway.controlUi.allowedOrigins` (or explicitly enable host-header fallback) in `openclaw.json`. - **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`. ## Useful commands diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index f5543ce1c45b..09c5587579b4 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -81,14 +81,14 @@ Use this if the gateway service keeps running but `openclaw` is missing. ### macOS (launchd) -Default label is `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may still exist): +Default label is `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may still exist): ```bash -launchctl bootout gui/$UID/bot.molt.gateway -rm -f ~/Library/LaunchAgents/bot.molt.gateway.plist +launchctl bootout gui/$UID/ai.openclaw.gateway +rm -f ~/Library/LaunchAgents/ai.openclaw.gateway.plist ``` -If you used a profile, replace the label and plist name with `bot.molt.`. Remove any legacy `com.openclaw.*` plists if present. +If you used a profile, replace the label and plist name with `ai.openclaw.`. Remove any legacy `com.openclaw.*` plists if present. ### Linux (systemd user unit) diff --git a/docs/install/updating.md b/docs/install/updating.md index e463a5001fbf..f94c26007765 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -71,6 +71,32 @@ See [Development channels](/install/development-channels) for channel semantics Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`. +### Core auto-updater (optional) + +Auto-updater is **off by default** and is a core Gateway feature (not a plugin). + +```json +{ + "update": { + "channel": "stable", + "auto": { + "enabled": true, + "stableDelayHours": 6, + "stableJitterHours": 12, + "betaCheckIntervalHours": 1 + } + } +} +``` + +Behavior: + +- `stable`: when a new version is seen, OpenClaw waits `stableDelayHours` and then applies a deterministic per-install jitter in `stableJitterHours` (spread rollout). +- `beta`: checks on `betaCheckIntervalHours` cadence (default: hourly) and applies when an update is available. +- `dev`: no automatic apply; use manual `openclaw update`. + +Use `openclaw update --dry-run` to preview update actions before enabling automation. + Then: ```bash @@ -170,7 +196,7 @@ openclaw logs --follow If you’re supervised: -- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/bot.molt.gateway` (use `bot.molt.`; legacy `com.openclaw.*` still works) +- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/ai.openclaw.gateway` (use `ai.openclaw.`; legacy `com.openclaw.*` still works) - Linux systemd user service: `systemctl --user restart openclaw-gateway[-].service` - Windows (WSL2): `systemctl --user restart openclaw-gateway[-].service` - `launchctl`/`systemctl` only work if the service is installed; otherwise run `openclaw gateway install`. diff --git a/docs/logging.md b/docs/logging.md index dafa1d878a52..34fb61ce42da 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -118,6 +118,8 @@ All logging configuration lives under `logging` in `~/.openclaw/openclaw.json`. - `logging.level`: **file logs** (JSONL) level. - `logging.consoleLevel`: **console** verbosity level. +You can override both via the **`OPENCLAW_LOG_LEVEL`** environment variable (e.g. `OPENCLAW_LOG_LEVEL=debug`). The env var takes precedence over the config file, so you can raise verbosity for a single run without editing `openclaw.json`. You can also pass the global CLI option **`--log-level `** (for example, `openclaw --log-level debug gateway run`), which overrides the environment variable for that command. + `--verbose` only affects console output; it does not change file log levels. ### Console styles diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md index 4d6208f245e1..f86fa0ea718a 100644 --- a/docs/nodes/audio.md +++ b/docs/nodes/audio.md @@ -94,11 +94,27 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI } ``` +### Provider-only (Mistral Voxtral) + +```json5 +{ + tools: { + media: { + audio: { + enabled: true, + models: [{ provider: "mistral", model: "voxtral-mini-latest" }], + }, + }, + }, +} +``` + ## Notes & limits - Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`). - Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used. - Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram). +- Mistral setup details: [Mistral](/providers/mistral). - Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`. - Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried. - Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 9a6f3f1f724d..70b1f6cae5f2 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -278,8 +278,11 @@ Notes: - `system.run` returns stdout/stderr/exit code in the payload. - `system.notify` respects notification permission state on the macOS app. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. +- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). +- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically. +- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form). - `system.notify` supports `--priority ` and `--delivery `. -- Node hosts ignore `PATH` overrides. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. +- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`. - On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals). Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`. - On headless node host, `system.run` is gated by exec approvals (`~/.openclaw/exec-approvals.json`). @@ -331,9 +334,9 @@ Notes: - The node host stores its node id, token, display name, and gateway connection info in `~/.openclaw/node.json`. - Exec approvals are enforced locally via `~/.openclaw/exec-approvals.json` (see [Exec approvals](/tools/exec-approvals)). -- On macOS, the headless node host prefers the companion app exec host when reachable and falls - back to local execution if the app is unavailable. Set `OPENCLAW_NODE_EXEC_HOST=app` to require - the app, or `OPENCLAW_NODE_EXEC_FALLBACK=0` to disable fallback. +- On macOS, the headless node host executes `system.run` locally by default. Set + `OPENCLAW_NODE_EXEC_HOST=app` to route `system.run` through the companion app exec host; add + `OPENCLAW_NODE_EXEC_FALLBACK=0` to require the app host and fail closed if it is unavailable. - Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS. ## Mac node mode diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index ed5fa009091a..6b9c78dece9d 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -175,11 +175,11 @@ If you omit `capabilities`, the entry is eligible for the list it appears in. ## Provider support matrix (OpenClaw integrations) -| Capability | Provider integration | Notes | -| ---------- | ------------------------------------------------ | ------------------------------------------------- | -| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | -| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). | -| Video | Google (Gemini API) | Provider video understanding. | +| Capability | Provider integration | Notes | +| ---------- | ------------------------------------------------ | --------------------------------------------------------- | +| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | +| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). | +| Video | Google (Gemini API) | Provider video understanding. | ## Recommended providers @@ -190,7 +190,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in. **Audio** -- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`. +- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, `deepgram/nova-3`, or `mistral/voxtral-mini-latest`. - CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`. - Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram). diff --git a/docs/nodes/troubleshooting.md b/docs/nodes/troubleshooting.md index ce815cdf00e9..c8ba10bac49c 100644 --- a/docs/nodes/troubleshooting.md +++ b/docs/nodes/troubleshooting.md @@ -86,6 +86,8 @@ If pairing is fine but `system.run` fails, fix exec approvals/allowlist. - `LOCATION_BACKGROUND_UNAVAILABLE` → app is backgrounded but only While Using permission exists. - `SYSTEM_RUN_DENIED: approval required` → exec request needs explicit approval. - `SYSTEM_RUN_DENIED: allowlist miss` → command blocked by allowlist mode. + On Windows node hosts, shell-wrapper forms like `cmd.exe /c ...` are treated as allowlist misses in + allowlist mode unless approved via ask flow. ## Fast recovery loop diff --git a/docs/pi-dev.md b/docs/pi-dev.md index 2eeebdcc2894..322bd13cd397 100644 --- a/docs/pi-dev.md +++ b/docs/pi-dev.md @@ -1,5 +1,9 @@ --- title: "Pi Development Workflow" +summary: "Developer workflow for Pi integration: build, test, and live validation" +read_when: + - Working on Pi integration code or tests + - Running Pi-specific lint, typecheck, and live test flows --- # Pi Development Workflow @@ -15,19 +19,25 @@ This guide summarizes a sane workflow for working on the pi integration in OpenC ## Running Pi Tests -Use the dedicated script for the pi integration test set: +Run the Pi-focused test set directly with Vitest: ```bash -scripts/pi/run-tests.sh +pnpm test -- \ + "src/agents/pi-*.test.ts" \ + "src/agents/pi-embedded-*.test.ts" \ + "src/agents/pi-tools*.test.ts" \ + "src/agents/pi-settings.test.ts" \ + "src/agents/pi-tool-definition-adapter*.test.ts" \ + "src/agents/pi-extensions/**/*.test.ts" ``` -To include the live test that exercises real provider behavior: +To include the live provider exercise: ```bash -scripts/pi/run-tests.sh --live +OPENCLAW_LIVE_TEST=1 pnpm test -- src/agents/pi-embedded-runner-extraparams.live.test.ts ``` -The script runs all pi related unit tests via these globs: +This covers the main Pi unit suites: - `src/agents/pi-*.test.ts` - `src/agents/pi-embedded-*.test.ts` diff --git a/docs/pi.md b/docs/pi.md index 71eafb661fe6..2689b480963e 100644 --- a/docs/pi.md +++ b/docs/pi.md @@ -1,5 +1,9 @@ --- title: "Pi Integration Architecture" +summary: "Architecture of OpenClaw's embedded Pi agent integration and session lifecycle" +read_when: + - Understanding Pi SDK integration design in OpenClaw + - Modifying agent session lifecycle, tooling, or provider wiring for Pi --- # Pi Integration Architecture @@ -228,6 +232,10 @@ await session.prompt(effectivePrompt, { images: imageResult.images }); The SDK handles the full agent loop: sending to LLM, executing tool calls, streaming responses. +Image injection is prompt-local: OpenClaw loads image refs from the current prompt and +passes them via `images` for that turn only. It does not re-scan older history turns +to re-inject image payloads. + ## Tool Architecture ### Tool Pipeline @@ -377,7 +385,7 @@ OpenClaw loads custom pi extensions for specialized behavior: ### Compaction Safeguard -`pi-extensions/compaction-safeguard.ts` adds guardrails to compaction, including adaptive token budgeting plus tool failure and file operation summaries: +`src/agents/pi-extensions/compaction-safeguard.ts` adds guardrails to compaction, including adaptive token budgeting plus tool failure and file operation summaries: ```typescript if (resolveCompactionMode(params.cfg) === "safeguard") { @@ -388,7 +396,7 @@ if (resolveCompactionMode(params.cfg) === "safeguard") { ### Context Pruning -`pi-extensions/context-pruning.ts` implements cache-TTL based context pruning: +`src/agents/pi-extensions/context-pruning.ts` implements cache-TTL based context pruning: ```typescript if (cfg?.agents?.defaults?.contextPruning?.mode === "cache-ttl") { @@ -533,80 +541,22 @@ Areas for potential rework: ## Tests -All existing tests that cover the pi integration and its extensions: - -- `src/agents/pi-embedded-block-chunker.test.ts` -- `src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts` -- `src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts` -- `src/agents/pi-embedded-helpers.downgradeopenai-reasoning.test.ts` -- `src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts` -- `src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts` -- `src/agents/pi-embedded-helpers.image-dimension-error.test.ts` -- `src/agents/pi-embedded-helpers.image-size-error.test.ts` -- `src/agents/pi-embedded-helpers.isautherrormessage.test.ts` -- `src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts` -- `src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.test.ts` -- `src/agents/pi-embedded-helpers.iscompactionfailureerror.test.ts` -- `src/agents/pi-embedded-helpers.iscontextoverflowerror.test.ts` -- `src/agents/pi-embedded-helpers.isfailovererrormessage.test.ts` -- `src/agents/pi-embedded-helpers.islikelycontextoverflowerror.test.ts` -- `src/agents/pi-embedded-helpers.ismessagingtoolduplicate.test.ts` -- `src/agents/pi-embedded-helpers.messaging-duplicate.test.ts` -- `src/agents/pi-embedded-helpers.normalizetextforcomparison.test.ts` -- `src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.test.ts` -- `src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.test.ts` -- `src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts` -- `src/agents/pi-embedded-helpers.sanitizegoogleturnordering.test.ts` -- `src/agents/pi-embedded-helpers.sanitizesessionmessagesimages-thought-signature-stripping.test.ts` -- `src/agents/pi-embedded-helpers.sanitizetoolcallid.test.ts` -- `src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts` -- `src/agents/pi-embedded-helpers.stripthoughtsignatures.test.ts` -- `src/agents/pi-embedded-helpers.validate-turns.test.ts` -- `src/agents/pi-embedded-runner-extraparams.live.test.ts` (live) -- `src/agents/pi-embedded-runner-extraparams.test.ts` -- `src/agents/pi-embedded-runner.applygoogleturnorderingfix.test.ts` -- `src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts` -- `src/agents/pi-embedded-runner.createsystempromptoverride.test.ts` -- `src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.falls-back-provider-default-per-dm-not.test.ts` -- `src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts` -- `src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts` -- `src/agents/pi-embedded-runner.guard.test.ts` -- `src/agents/pi-embedded-runner.limithistoryturns.test.ts` -- `src/agents/pi-embedded-runner.resolvesessionagentids.test.ts` -- `src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts` -- `src/agents/pi-embedded-runner.sanitize-session-history.test.ts` -- `src/agents/pi-embedded-runner.splitsdktools.test.ts` -- `src/agents/pi-embedded-runner.test.ts` -- `src/agents/pi-embedded-subscribe.code-span-awareness.test.ts` -- `src/agents/pi-embedded-subscribe.reply-tags.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-reasoning-as-separate-message-enabled.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts` -- `src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.test.ts` -- `src/agents/pi-embedded-subscribe.tools.test.ts` -- `src/agents/pi-embedded-utils.test.ts` -- `src/agents/pi-extensions/compaction-safeguard.test.ts` -- `src/agents/pi-extensions/context-pruning.test.ts` +Pi integration coverage spans these suites: + +- `src/agents/pi-*.test.ts` +- `src/agents/pi-auth-json.test.ts` +- `src/agents/pi-embedded-*.test.ts` +- `src/agents/pi-embedded-helpers*.test.ts` +- `src/agents/pi-embedded-runner*.test.ts` +- `src/agents/pi-embedded-runner/**/*.test.ts` +- `src/agents/pi-embedded-subscribe*.test.ts` +- `src/agents/pi-tools*.test.ts` +- `src/agents/pi-tool-definition-adapter*.test.ts` - `src/agents/pi-settings.test.ts` -- `src/agents/pi-tool-definition-adapter.test.ts` -- `src/agents/pi-tools-agent-config.test.ts` -- `src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts` -- `src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts` -- `src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts` -- `src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts` -- `src/agents/pi-tools.policy.test.ts` -- `src/agents/pi-tools.safe-bins.test.ts` -- `src/agents/pi-tools.workspace-paths.test.ts` +- `src/agents/pi-extensions/**/*.test.ts` + +Live/opt-in: + +- `src/agents/pi-embedded-runner-extraparams.live.test.ts` (enable `OPENCLAW_LIVE_TEST=1`) + +For current run commands, see [Pi Development Workflow](/pi-dev). diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 0f37c275cd3c..ec2663aefe40 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -49,5 +49,5 @@ Use one of these (all supported): The service target depends on OS: -- macOS: LaunchAgent (`bot.molt.gateway` or `bot.molt.`; legacy `com.openclaw.*`) +- macOS: LaunchAgent (`ai.openclaw.gateway` or `ai.openclaw.`; legacy `com.openclaw.*`) - Linux/WSL2: systemd user service (`openclaw-gateway[-].service`) diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 54064656dca3..6cb878015fb4 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -28,12 +28,12 @@ The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun no Label: -- `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may remain) +- `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may remain) Plist location (per‑user): -- `~/Library/LaunchAgents/bot.molt.gateway.plist` - (or `~/Library/LaunchAgents/bot.molt..plist`) +- `~/Library/LaunchAgents/ai.openclaw.gateway.plist` + (or `~/Library/LaunchAgents/ai.openclaw..plist`) Manager: diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index e009a58257c1..b65ca5f0d9d4 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -18,8 +18,8 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. ## Default behavior (launchd) -- The app installs a per‑user LaunchAgent labeled `bot.molt.gateway` - (or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). +- The app installs a per‑user LaunchAgent labeled `ai.openclaw.gateway` + (or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). - When Local mode is enabled, the app ensures the LaunchAgent is loaded and starts the Gateway if needed. - Logs are written to the launchd gateway log path (visible in Debug Settings). @@ -27,11 +27,11 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. Common commands: ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. ## Unsigned dev builds diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 8aff51348862..e50a850086ac 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -84,7 +84,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* 1. Reset the TCC permissions: ```bash - tccutil reset All bot.molt.mac.debug + tccutil reset All ai.openclaw.mac.debug ``` 2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS. diff --git a/docs/platforms/mac/logging.md b/docs/platforms/mac/logging.md index c1abf717cc95..5e1af460e3c7 100644 --- a/docs/platforms/mac/logging.md +++ b/docs/platforms/mac/logging.md @@ -26,12 +26,12 @@ Notes: Unified logging redacts most payloads unless a subsystem opts into `privacy -off`. Per Peter's write-up on macOS [logging privacy shenanigans](https://steipete.me/posts/2025/logging-privacy-shenanigans) (2025) this is controlled by a plist in `/Library/Preferences/Logging/Subsystems/` keyed by the subsystem name. Only new log entries pick up the flag, so enable it before reproducing an issue. -## Enable for OpenClaw (`bot.molt`) +## Enable for OpenClaw (`ai.openclaw`) - Write the plist to a temp file first, then install it atomically as root: ```bash -cat <<'EOF' >/tmp/bot.molt.plist +cat <<'EOF' >/tmp/ai.openclaw.plist @@ -44,7 +44,7 @@ cat <<'EOF' >/tmp/bot.molt.plist EOF -sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Logging/Subsystems/bot.molt.plist +sudo install -m 644 -o root -g wheel /tmp/ai.openclaw.plist /Library/Preferences/Logging/Subsystems/ai.openclaw.plist ``` - No reboot is required; logd notices the file quickly, but only new log lines will include private payloads. @@ -52,6 +52,6 @@ sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Lo ## Disable after debugging -- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/bot.molt.plist`. +- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/ai.openclaw.plist`. - Optionally run `sudo log config --reload` to force logd to drop the override immediately. - Remember this surface can include phone numbers and message bodies; keep the plist in place only while you actively need the extra detail. diff --git a/docs/platforms/mac/permissions.md b/docs/platforms/mac/permissions.md index 12f75eb9f511..e749ecf9d771 100644 --- a/docs/platforms/mac/permissions.md +++ b/docs/platforms/mac/permissions.md @@ -35,8 +35,8 @@ grants, and prompts can disappear entirely until the stale entries are cleared. Example resets (replace bundle ID as needed): ```bash -sudo tccutil reset Accessibility bot.molt.mac -sudo tccutil reset ScreenCapture bot.molt.mac +sudo tccutil reset Accessibility ai.openclaw.mac +sudo tccutil reset ScreenCapture ai.openclaw.mac sudo tccutil reset AppleEvents ``` diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 7d3a8d0190b5..57e68f53f053 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -33,33 +33,33 @@ Notes: ```bash # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +BUNDLE_ID=ai.openclaw.mac \ +APP_VERSION=2026.2.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.26.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.26.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: # xcrun notarytool store-credentials "openclaw-notary" \ # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +BUNDLE_ID=ai.openclaw.mac \ +APP_VERSION=2026.2.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.26.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.21.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.26.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.21.zip` (and `OpenClaw-2026.2.21.dSYM.zip`) to the GitHub release for tag `v2026.2.21`. +- Upload `OpenClaw-2026.2.26.zip` (and `OpenClaw-2026.2.26.dSYM.zip`) to the GitHub release for tag `v2026.2.26`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/docs/platforms/mac/voice-overlay.md b/docs/platforms/mac/voice-overlay.md index 9c42601b1865..86f02d9ed244 100644 --- a/docs/platforms/mac/voice-overlay.md +++ b/docs/platforms/mac/voice-overlay.md @@ -37,7 +37,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Push-to-talk: no delay; wake-word: optional delay for auto-send. - Apply a short cooldown to the wake runtime after push-to-talk finishes so wake-word doesn’t immediately retrigger. 5. **Logging** - - Coordinator emits `.info` logs in subsystem `bot.molt`, categories `voicewake.overlay` and `voicewake.chime`. + - Coordinator emits `.info` logs in subsystem `ai.openclaw`, categories `voicewake.overlay` and `voicewake.chime`. - Key events: `session_started`, `adopted_by_push_to_talk`, `partial`, `finalized`, `send`, `dismiss`, `cancel`, `cooldown`. ## Debugging checklist @@ -45,7 +45,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Stream logs while reproducing a sticky overlay: ```bash - sudo log stream --predicate 'subsystem == "bot.molt" AND category CONTAINS "voicewake"' --level info --style compact + sudo log stream --predicate 'subsystem == "ai.openclaw" AND category CONTAINS "voicewake"' --level info --style compact ``` - Verify only one active session token; stale callbacks should be dropped by the coordinator. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index ea6791ff50e8..11b500a8596d 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -24,7 +24,7 @@ agent (with a session switcher for other sessions). dist/OpenClaw.app/Contents/MacOS/OpenClaw --webchat ``` -- Logs: `./scripts/clawlog.sh` (subsystem `bot.molt`, category `WebChatSwiftUI`). +- Logs: `./scripts/clawlog.sh` (subsystem `ai.openclaw`, category `WebChatSwiftUI`). ## How it’s wired diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 730d7015ad57..04c61df266af 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -34,15 +34,15 @@ capabilities to the agent as a node. ## Launchd control -The app manages a per‑user LaunchAgent labeled `bot.molt.gateway` -(or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). +The app manages a per‑user LaunchAgent labeled `ai.openclaw.gateway` +(or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. If the LaunchAgent isn’t installed, enable it from the app or run `openclaw gateway install`. @@ -105,7 +105,9 @@ Notes: - `allowlist` entries are glob patterns for resolved binary paths. - Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary). - Choosing “Always Allow” in the prompt adds that command to the allowlist. -- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment. +- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app’s environment. +- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). +- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically. ## Deep links diff --git a/docs/plugins/community.md b/docs/plugins/community.md index c135381676ce..94c6ddbe00d8 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -42,3 +42,10 @@ Use this format when adding entries: npm: `@scope/package` repo: `https://github.com/org/repo` install: `openclaw plugins install @scope/package` + +## Listed plugins + +- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. + npm: `@icesword760/openclaw-wechat` + repo: `https://github.com/icesword0760/openclaw-wechat` + install: `openclaw plugins install @icesword760/openclaw-wechat` diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index aba63555026c..17263ca05093 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -107,6 +107,10 @@ Set config under `plugins.entries.voice-call.config`: streaming: { enabled: true, streamPath: "/voice/stream", + preStartTimeoutMs: 5000, + maxPendingConnections: 32, + maxPendingConnectionsPerIp: 4, + maxConnections: 128, }, }, }, @@ -125,6 +129,11 @@ Notes: - If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. - Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel. +- Streaming security defaults: + - `streaming.preStartTimeoutMs` closes sockets that never send a valid `start` frame. + - `streaming.maxPendingConnections` caps total unauthenticated pre-start sockets. + - `streaming.maxPendingConnectionsPerIp` caps unauthenticated pre-start sockets per source IP. + - `streaming.maxConnections` caps total open media stream sockets (pending + active). ## Stale call reaper @@ -168,6 +177,12 @@ headers are trusted. `webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request remote IP matches the list. +Webhook replay protection is enabled for Twilio and Plivo. Replayed valid webhook +requests are acknowledged but skipped for side effects. + +Twilio conversation turns include a per-turn token in `` callbacks, so +stale/replayed speech callbacks cannot satisfy a newer pending transcript turn. + Example with a stable public host: ```json5 diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 6f9759b3b2ff..40f86630dba9 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -67,6 +67,42 @@ Use the `cacheRetention` parameter in your model config: When using Anthropic API Key authentication, OpenClaw automatically applies `cacheRetention: "short"` (5-minute cache) for all Anthropic models. You can override this by explicitly setting `cacheRetention` in your config. +### Per-agent cacheRetention overrides + +Use model-level params as your baseline, then override specific agents via `agents.list[].params`. + +```json5 +{ + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { + "anthropic/claude-opus-4-6": { + params: { cacheRetention: "long" }, // baseline for most agents + }, + }, + }, + list: [ + { id: "research", default: true }, + { id: "alerts", params: { cacheRetention: "none" } }, // override for this agent only + ], + }, +} +``` + +Config merge order for cache-related params: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching `id`, overrides by key) + +This lets one agent keep a long-lived cache while another agent on the same model disables caching to avoid write costs on bursty/low-reuse traffic. + +### Bedrock Claude notes + +- Anthropic Claude models on Bedrock (`amazon-bedrock/*anthropic.claude*`) accept `cacheRetention` pass-through when configured. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. +- Anthropic API-key smart defaults also seed `cacheRetention: "short"` for Claude-on-Bedrock model refs when no explicit value is set. + ### Legacy parameter The older `cacheControlTtl` parameter is still supported for backwards compatibility: @@ -101,6 +137,10 @@ with `params.context1m: true` for supported Opus/Sonnet models. OpenClaw maps this to `anthropic-beta: context-1m-2025-08-07` on Anthropic requests. +Note: Anthropic currently rejects `context-1m-*` beta requests when using +OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the +context1m beta header for OAuth auth and keeps the required OAuth betas. + ## Option B: Claude setup-token **Best for:** using your Claude subscription. diff --git a/docs/providers/index.md b/docs/providers/index.md index 7bf51ff21d4a..50c02463af7e 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -44,6 +44,7 @@ See [Venice AI](/providers/venice). - [Together AI](/providers/together) - [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway) - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) +- [Mistral](/providers/mistral) - [OpenCode Zen](/providers/opencode) - [Amazon Bedrock](/providers/bedrock) - [Z.AI](/providers/zai) diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md new file mode 100644 index 000000000000..146e22932c4a --- /dev/null +++ b/docs/providers/kilocode.md @@ -0,0 +1,64 @@ +--- +summary: "Use Kilo Gateway's unified API to access many models in OpenClaw" +read_when: + - You want a single API key for many LLMs + - You want to run models via Kilo Gateway in OpenClaw +--- + +# Kilo Gateway + +Kilo Gateway provides a **unified API** that routes requests to many models behind a single +endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL. + +## Getting an API key + +1. Go to [app.kilo.ai](https://app.kilo.ai) +2. Sign in or create an account +3. Navigate to API Keys and generate a new key + +## CLI setup + +```bash +openclaw onboard --kilocode-api-key +``` + +Or set the environment variable: + +```bash +export KILOCODE_API_KEY="your-api-key" +``` + +## Config snippet + +```json5 +{ + env: { KILOCODE_API_KEY: "sk-..." }, + agents: { + defaults: { + model: { primary: "kilocode/anthropic/claude-opus-4.6" }, + }, + }, +} +``` + +## Surfaced model refs + +The built-in Kilo Gateway catalog currently surfaces these model refs: + +- `kilocode/anthropic/claude-opus-4.6` (default) +- `kilocode/z-ai/glm-5:free` +- `kilocode/minimax/minimax-m2.5:free` +- `kilocode/anthropic/claude-sonnet-4.5` +- `kilocode/openai/gpt-5.2` +- `kilocode/google/gemini-3-pro-preview` +- `kilocode/google/gemini-3-flash-preview` +- `kilocode/x-ai/grok-code-fast-1` +- `kilocode/moonshotai/kimi-k2.5` + +## Notes + +- Model refs are `kilocode//` (e.g., `kilocode/anthropic/claude-opus-4.6`). +- Default model: `kilocode/anthropic/claude-opus-4.6` +- Base URL: `https://api.kilo.ai/api/gateway/` +- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers). +- Kilo Gateway uses a Bearer token with your API key under the hood. diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md new file mode 100644 index 000000000000..44e594abf216 --- /dev/null +++ b/docs/providers/mistral.md @@ -0,0 +1,54 @@ +--- +summary: "Use Mistral models and Voxtral transcription with OpenClaw" +read_when: + - You want to use Mistral models in OpenClaw + - You need Mistral API key onboarding and model refs +title: "Mistral" +--- + +# Mistral + +OpenClaw supports Mistral for both text/image model routing (`mistral/...`) and +audio transcription via Voxtral in media understanding. +Mistral can also be used for memory embeddings (`memorySearch.provider = "mistral"`). + +## CLI setup + +```bash +openclaw onboard --auth-choice mistral-api-key +# or non-interactive +openclaw onboard --mistral-api-key "$MISTRAL_API_KEY" +``` + +## Config snippet (LLM provider) + +```json5 +{ + env: { MISTRAL_API_KEY: "sk-..." }, + agents: { defaults: { model: { primary: "mistral/mistral-large-latest" } } }, +} +``` + +## Config snippet (audio transcription with Voxtral) + +```json5 +{ + tools: { + media: { + audio: { + enabled: true, + models: [{ provider: "mistral", model: "voxtral-mini-latest" }], + }, + }, + }, +} +``` + +## Notes + +- Mistral auth uses `MISTRAL_API_KEY`. +- Provider base URL defaults to `https://api.mistral.ai/v1`. +- Onboarding default model is `mistral/mistral-large-latest`. +- Media-understanding default audio model for Mistral is `voxtral-mini-latest`. +- Media transcription path uses `/v1/audio/transcriptions`. +- Memory embeddings path uses `/v1/embeddings` (default model: `mistral-embed`). diff --git a/docs/providers/models.md b/docs/providers/models.md index aff92bd07412..f71c599698e9 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -39,6 +39,7 @@ See [Venice AI](/providers/venice). - [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway) - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) +- [Mistral](/providers/mistral) - [Synthetic](/providers/synthetic) - [OpenCode Zen](/providers/opencode) - [Z.AI](/providers/zai) diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 54e3d29e4548..1a47081a9a6d 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -56,6 +56,33 @@ openclaw models auth login --provider openai-codex } ``` +### Codex transport default + +OpenClaw uses `pi-ai` for model streaming. For `openai-codex/*` models you can set +`agents.defaults.models..params.transport` to select transport: + +- Default is `"auto"` (WebSocket-first, then SSE fallback). +- `"sse"`: force SSE +- `"websocket"`: force WebSocket +- `"auto"`: try WebSocket, then fall back to SSE + +```json5 +{ + agents: { + defaults: { + model: { primary: "openai-codex/gpt-5.3-codex" }, + models: { + "openai-codex/gpt-5.3-codex": { + params: { + transport: "auto", + }, + }, + }, + }, + }, +} +``` + ## Notes - Model refs always use `provider/model` (see [/concepts/models](/concepts/models)). diff --git a/docs/providers/together.md b/docs/providers/together.md index f840ea35e801..62bab43a204f 100644 --- a/docs/providers/together.md +++ b/docs/providers/together.md @@ -47,7 +47,7 @@ This will set `together/moonshotai/Kimi-K2.5` as the default model. ## Environment note If the Gateway runs as a daemon (launchd/systemd), make sure `TOGETHER_API_KEY` -is available to that process (for example, in `~/.clawdbot/.env` or via +is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`). ## Available models diff --git a/docs/providers/venice.md b/docs/providers/venice.md index 02d89ca7f83f..4b7e55086653 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -79,7 +79,7 @@ openclaw onboard --non-interactive \ ### 3. Verify Setup ```bash -openclaw chat --model venice/llama-3.3-70b "Hello, are you working?" +openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?" ``` ## Model Selection @@ -195,19 +195,19 @@ Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/p ```bash # Use default private model -openclaw chat --model venice/llama-3.3-70b +openclaw agent --model venice/llama-3.3-70b --message "Quick health check" # Use Claude via Venice (anonymized) -openclaw chat --model venice/claude-opus-45 +openclaw agent --model venice/claude-opus-45 --message "Summarize this task" # Use uncensored model -openclaw chat --model venice/venice-uncensored +openclaw agent --model venice/venice-uncensored --message "Draft options" # Use vision model with image -openclaw chat --model venice/qwen3-vl-235b-a22b +openclaw agent --model venice/qwen3-vl-235b-a22b --message "Review attached image" # Use coding model -openclaw chat --model venice/qwen3-coder-480b-a35b-instruct +openclaw agent --model venice/qwen3-coder-480b-a35b-instruct --message "Refactor this function" ``` ## Troubleshooting diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 726a6040fcc7..3b5053fbac7c 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -48,3 +48,11 @@ openclaw onboard --non-interactive \ If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY` is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`). + +## Model ID shorthand + +OpenClaw accepts Vercel Claude shorthand model refs and normalizes them at +runtime: + +- `vercel-ai-gateway/claude-opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4.6` +- `vercel-ai-gateway/opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4-6` diff --git a/docs/refactor/outbound-session-mirroring.md b/docs/refactor/outbound-session-mirroring.md index d30e9683eb12..4f712541658d 100644 --- a/docs/refactor/outbound-session-mirroring.md +++ b/docs/refactor/outbound-session-mirroring.md @@ -1,6 +1,10 @@ --- title: Outbound Session Mirroring Refactor (Issue #1520) description: Track outbound session mirroring refactor notes, decisions, tests, and open items. +summary: "Refactor notes for mirroring outbound sends into target channel sessions" +read_when: + - Working on outbound transcript/session mirroring behavior + - Debugging sessionKey derivation for send/message tool paths --- # Outbound Session Mirroring Refactor (Issue #1520) @@ -58,7 +62,7 @@ Outbound sends were mirrored into the _current_ agent session (tool session key) ## Tests Added/Updated -- `src/infra/outbound/outbound-session.test.ts` +- `src/infra/outbound/outbound.test.ts` - Slack thread session key. - Telegram topic session key. - dmScope identityLinks with Discord. @@ -80,6 +84,6 @@ Outbound sends were mirrored into the _current_ agent session (tool session key) - `src/agents/tools/message-tool.ts` - `src/gateway/server-methods/send.ts` - Tests in: - - `src/infra/outbound/outbound-session.test.ts` + - `src/infra/outbound/outbound.test.ts` - `src/agents/tools/message-tool.test.ts` - `src/gateway/server-methods/send.test.ts` diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 0f9f37acb5b0..6b5dc29c9b93 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -23,7 +23,7 @@ When the operator says “release”, immediately do this preflight (no extra qu - [ ] Bump `package.json` version (e.g., `2026.1.29`). - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. -- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts). +- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts). - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`. - [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 0eb95171412f..58fec7538fa2 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -67,6 +67,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid - `memorySearch.provider = "openai"` → OpenAI embeddings - `memorySearch.provider = "gemini"` → Gemini embeddings - `memorySearch.provider = "voyage"` → Voyage embeddings +- `memorySearch.provider = "mistral"` → Mistral embeddings - Optional fallback to a remote provider if local embeddings fail You can keep it local with `memorySearch.provider = "local"` (no API usage). diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md new file mode 100644 index 000000000000..67561e4a21b4 --- /dev/null +++ b/docs/reference/prompt-caching.md @@ -0,0 +1,185 @@ +--- +title: "Prompt Caching" +summary: "Prompt caching knobs, merge order, provider behavior, and tuning patterns" +read_when: + - You want to reduce prompt token costs with cache retention + - You need per-agent cache behavior in multi-agent setups + - You are tuning heartbeat and cache-ttl pruning together +--- + +# Prompt caching + +Prompt caching means the model provider can reuse unchanged prompt prefixes (usually system/developer instructions and other stable context) across turns instead of re-processing them every time. The first matching request writes cache tokens (`cacheWrite`), and later matching requests can read them back (`cacheRead`). + +Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. + +This page covers all cache-related knobs that affect prompt reuse and token cost. + +For Anthropic pricing details, see: +[https://docs.anthropic.com/docs/build-with-claude/prompt-caching](https://docs.anthropic.com/docs/build-with-claude/prompt-caching) + +## Primary knobs + +### `cacheRetention` (model and per-agent) + +Set cache retention on model params: + +```yaml +agents: + defaults: + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "short" # none | short | long +``` + +Per-agent override: + +```yaml +agents: + list: + - id: "alerts" + params: + cacheRetention: "none" +``` + +Config merge order: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching agent id; overrides by key) + +### Legacy `cacheControlTtl` + +Legacy values are still accepted and mapped: + +- `5m` -> `short` +- `1h` -> `long` + +Prefer `cacheRetention` for new config. + +### `contextPruning.mode: "cache-ttl"` + +Prunes old tool-result context after cache TTL windows so post-idle requests do not re-cache oversized history. + +```yaml +agents: + defaults: + contextPruning: + mode: "cache-ttl" + ttl: "1h" +``` + +See [Session Pruning](/concepts/session-pruning) for full behavior. + +### Heartbeat keep-warm + +Heartbeat can keep cache windows warm and reduce repeated cache writes after idle gaps. + +```yaml +agents: + defaults: + heartbeat: + every: "55m" +``` + +Per-agent heartbeat is supported at `agents.list[].heartbeat`. + +## Provider behavior + +### Anthropic (direct API) + +- `cacheRetention` is supported. +- With Anthropic API-key auth profiles, OpenClaw seeds `cacheRetention: "short"` for Anthropic model refs when unset. + +### Amazon Bedrock + +- Anthropic Claude model refs (`amazon-bedrock/*anthropic.claude*`) support explicit `cacheRetention` pass-through. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. + +### OpenRouter Anthropic models + +For `openrouter/anthropic/*` model refs, OpenClaw injects Anthropic `cache_control` on system/developer prompt blocks to improve prompt-cache reuse. + +### Other providers + +If the provider does not support this cache mode, `cacheRetention` has no effect. + +## Tuning patterns + +### Mixed traffic (recommended default) + +Keep a long-lived baseline on your main agent, disable caching on bursty notifier agents: + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" + list: + - id: "research" + default: true + heartbeat: + every: "55m" + - id: "alerts" + params: + cacheRetention: "none" +``` + +### Cost-first baseline + +- Set baseline `cacheRetention: "short"`. +- Enable `contextPruning.mode: "cache-ttl"`. +- Keep heartbeat below your TTL only for agents that benefit from warm caches. + +## Cache diagnostics + +OpenClaw exposes dedicated cache-trace diagnostics for embedded agent runs. + +### `diagnostics.cacheTrace` config + +```yaml +diagnostics: + cacheTrace: + enabled: true + filePath: "~/.openclaw/logs/cache-trace.jsonl" # optional + includeMessages: false # default true + includePrompt: false # default true + includeSystem: false # default true +``` + +Defaults: + +- `filePath`: `$OPENCLAW_STATE_DIR/logs/cache-trace.jsonl` +- `includeMessages`: `true` +- `includePrompt`: `true` +- `includeSystem`: `true` + +### Env toggles (one-off debugging) + +- `OPENCLAW_CACHE_TRACE=1` enables cache tracing. +- `OPENCLAW_CACHE_TRACE_FILE=/path/to/cache-trace.jsonl` overrides output path. +- `OPENCLAW_CACHE_TRACE_MESSAGES=0|1` toggles full message payload capture. +- `OPENCLAW_CACHE_TRACE_PROMPT=0|1` toggles prompt text capture. +- `OPENCLAW_CACHE_TRACE_SYSTEM=0|1` toggles system prompt capture. + +### What to inspect + +- Cache trace events are JSONL and include staged snapshots like `session:loaded`, `prompt:before`, `stream:context`, and `session:after`. +- Per-turn cache token impact is visible in normal usage surfaces via `cacheRead` and `cacheWrite` (for example `/usage full` and session usage summaries). + +## Quick troubleshooting + +- High `cacheWrite` on most turns: check for volatile system-prompt inputs and verify model/provider supports your cache settings. +- No effect from `cacheRetention`: confirm model key matches `agents.defaults.models["provider/model"]`. +- Bedrock Nova/Mistral requests with cache settings: expected runtime force to `none`. + +Related docs: + +- [Anthropic](/providers/anthropic) +- [Token Use and Costs](/reference/token-use) +- [Session Pruning](/concepts/session-pruning) +- [Gateway Configuration Reference](/gateway/configuration-reference) diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 3a08575454ed..d258eeb6722c 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -65,6 +65,44 @@ OpenClaw resolves these via `src/config/sessions.ts`. --- +## Store maintenance and disk controls + +Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json` and transcript artifacts: + +- `mode`: `warn` (default) or `enforce` +- `pruneAfter`: stale-entry age cutoff (default `30d`) +- `maxEntries`: cap entries in `sessions.json` (default `500`) +- `rotateBytes`: rotate `sessions.json` when oversized (default `10mb`) +- `resetArchiveRetention`: retention for `*.reset.` transcript archives (default: same as `pruneAfter`; `false` disables cleanup) +- `maxDiskBytes`: optional sessions-directory budget +- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) + +Enforcement order for disk budget cleanup (`mode: "enforce"`): + +1. Remove oldest archived or orphan transcript artifacts first. +2. If still above the target, evict oldest session entries and their transcript files. +3. Keep going until usage is at or below `highWaterBytes`. + +In `mode: "warn"`, OpenClaw reports potential evictions but does not mutate the store/files. + +Run maintenance on demand: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + +--- + +## Cron sessions and run logs + +Isolated cron runs also create session entries/transcripts, and they have dedicated retention controls: + +- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables). +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl` files (defaults: `2_000_000` bytes and `2000` lines). + +--- + ## Session keys (`sessionKey`) A `sessionKey` identifies _which conversation bucket_ you’re in (routing + isolation). @@ -90,6 +128,7 @@ Rules of thumb: - **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`. - **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary. - **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins. +- **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. diff --git a/docs/reference/test.md b/docs/reference/test.md index 91db2244bd04..e369b4da7adf 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -15,6 +15,19 @@ title: "Tests" - `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. - `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. +## Local PR gate + +For local PR land/gate checks, run: + +- `pnpm check` +- `pnpm build` +- `pnpm test` +- `pnpm check:docs` + +If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run `. For memory-constrained hosts, use: + +- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` + ## Model latency bench (local keys) Script: [`scripts/bench-model.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-model.ts) diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 7f04e19650fb..9127e2477e00 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -88,6 +88,11 @@ Heartbeat can keep the cache **warm** across idle gaps. If your model cache TTL is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid re-caching the full prompt, reducing cache write costs. +In multi-agent setups, you can keep one shared model config and tune cache behavior +per agent with `agents.list[].params.cacheRetention`. + +For a full knob-by-knob guide, see [Prompt Caching](/reference/prompt-caching). + For Anthropic API pricing, cache reads are significantly cheaper than input tokens, while cache writes are billed at a higher multiplier. See Anthropic’s prompt caching pricing for the latest rates and TTL multipliers: @@ -108,6 +113,30 @@ agents: every: "55m" ``` +### Example: mixed traffic with per-agent cache strategy + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" # default baseline for most agents + list: + - id: "research" + default: true + heartbeat: + every: "55m" # keep long cache warm for deep sessions + - id: "alerts" + params: + cacheRetention: "none" # avoid cache writes for bursty notifications +``` + +`agents.list[].params` merges on top of the selected model's `params`, so you can +override only `cacheRetention` and inherit other model defaults unchanged. + ### Example: enable Anthropic 1M context beta header Anthropic's 1M context window is currently beta-gated. OpenClaw can inject the @@ -125,6 +154,10 @@ agents: This maps to Anthropic's `context-1m-2025-08-07` beta header. +If you authenticate Anthropic with OAuth/subscription tokens (`sk-ant-oat-*`), +OpenClaw skips the `context-1m-*` beta header because Anthropic currently +rejects that combination with HTTP 401. + ## Tips for reducing token pressure - Use `/compact` to summarize long sessions. diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 19191252e119..4f85e7e866d4 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -20,6 +20,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**. - Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). + - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` + to also remove workspace. - If the config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing. - Reset uses `trash` (never `rm`) and offers scopes: @@ -34,7 +36,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. - **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`. - Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.openclaw/.env` so launchd can read it. + - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles. - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider. - **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth). - **API key**: stores the key for you. @@ -52,6 +54,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - **Skip**: no auth configured yet. - Pick a default model from detected options (or enter provider/model manually). - Wizard runs a model check and warns if the configured model is unknown or missing auth. + - API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`). - OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents//agent/auth-profiles.json` (API keys + OAuth). - More detail: [/concepts/oauth](/concepts/oauth) @@ -243,6 +246,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible). - `skills.install.nodeManager` diff --git a/docs/security/formal-verification.md b/docs/security/formal-verification.md index a45e63f3c113..ae650b5b7c20 100644 --- a/docs/security/formal-verification.md +++ b/docs/security/formal-verification.md @@ -1,6 +1,9 @@ --- title: Formal Verification (Security Models) summary: Machine-checked security models for OpenClaw’s highest-risk paths. +read_when: + - Reviewing formal security model guarantees or limits + - Reproducing or updating TLA+/TLC security model checks permalink: /security/formal-verification/ --- diff --git a/docs/start/hubs.md b/docs/start/hubs.md index b573b6009aa2..082ebc4b7414 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -181,8 +181,6 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Experiments (exploratory) - [Onboarding config protocol](/experiments/onboarding-config-protocol) -- [Cron hardening notes](/experiments/plans/cron-add-hardening) -- [Group policy hardening notes](/experiments/plans/group-policy-hardening) - [Research: memory](/experiments/research/memory) - [Model config exploration](/experiments/proposals/model-config) diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index ab9289b8a110..dfa058af545b 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -29,6 +29,12 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb + +Security trust model: + +- By default, OpenClaw is a personal agent: one trusted operator boundary. +- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). + @@ -37,17 +43,19 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb Where does the **Gateway** run? -- **This Mac (Local only):** onboarding can run OAuth flows and write credentials +- **This Mac (Local only):** onboarding can configure auth and write credentials locally. -- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally; +- **Remote (over SSH/Tailnet):** onboarding does **not** configure local auth; credentials must exist on the gateway host. - **Configure later:** skip setup and leave the app unconfigured. **Gateway auth tip:** + - The wizard now generates a **token** even for loopback, so local WS clients must authenticate. - If you disable auth, any local process can connect; use that only on fully trusted machines. - Use a **token** for multi‑machine access or non‑loopback binds. + diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6a..671efe420c72 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,6 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. +- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/docs/start/setup.md b/docs/start/setup.md index ee50e02afd47..d1fbb7edf7e1 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -130,8 +130,11 @@ Use this when debugging auth or deciding what to back up: - **Telegram bot token**: config/env or `channels.telegram.tokenFile` - **Discord bot token**: config/env (token file not yet supported) - **Slack tokens**: config/env (`channels.slack.*`) -- **Pairing allowlists**: `~/.openclaw/credentials/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//agent/auth-profiles.json` +- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json` - **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json` More detail: [Security](/gateway/security#credential-storage-map). diff --git a/docs/start/showcase.md b/docs/start/showcase.md index f84c17fb8761..347d8214cefb 100644 --- a/docs/start/showcase.md +++ b/docs/start/showcase.md @@ -2,6 +2,9 @@ title: "Showcase" description: "Real-world OpenClaw projects from the community" summary: "Community-built projects and integrations powered by OpenClaw" +read_when: + - Looking for real OpenClaw usage examples + - Updating community project highlights --- # Showcase diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 1eb85c36a106..14f4a9d5d32c 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -22,6 +22,7 @@ openclaw onboard --non-interactive \ --mode local \ --auth-choice apiKey \ --anthropic-api-key "$ANTHROPIC_API_KEY" \ + --secret-input-mode plaintext \ --gateway-port 18789 \ --gateway-bind loopback \ --install-daemon \ @@ -31,6 +32,22 @@ openclaw onboard --non-interactive \ Add `--json` for a machine-readable summary. +Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values. +Interactive selection between env refs and configured provider refs (`file` or `exec`) is available in the onboarding wizard flow. + +In non-interactive `ref` mode, provider env vars must be set in the process environment. +Passing inline key flags without the matching env var now fails fast. + +Example: + +```bash +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --accept-risk +``` + ## Provider-specific examples @@ -86,6 +103,16 @@ Add `--json` for a machine-readable summary. --gateway-bind loopback ``` + + ```bash + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice mistral-api-key \ + --mistral-api-key "$MISTRAL_API_KEY" \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + ```bash openclaw onboard --non-interactive \ @@ -122,6 +149,24 @@ Add `--json` for a machine-readable summary. `--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`. + Ref-mode variant: + + ```bash + export CUSTOM_API_KEY="your-key" + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice custom-api-key \ + --custom-base-url "https://llm.example.com/v1" \ + --custom-model-id "foo-large" \ + --secret-input-mode ref \ + --custom-provider-id "my-custom" \ + --custom-compatibility anthropic \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + + In this mode, onboarding stores `apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. + diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index b0b31de8c603..5019956a05cd 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -33,6 +33,7 @@ It does not install or modify anything on the remote host. - If `~/.openclaw/openclaw.json` exists, choose Keep, Modify, or Reset. - Re-running the wizard does not wipe anything unless you explicitly choose Reset (or pass `--reset`). + - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace. - If config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing. - Reset uses `trash` and offers scopes: - Config only @@ -139,8 +140,7 @@ What you set: - Uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to - `~/.openclaw/.env` so launchd can read it. + Uses `OPENAI_API_KEY` if present or prompts for a key, then stores the credential in auth profiles. Sets `agents.defaults.model` to `openai/gpt-5.1-codex` when model is unset, `openai/*`, or `openai-codex/*`. @@ -178,6 +178,10 @@ What you set: Works with OpenAI-compatible and Anthropic-compatible endpoints. + Interactive onboarding supports the same API key storage choices as other provider API key flows: + - **Paste API key now** (plaintext) + - **Use secret reference** (env ref or configured provider ref, with preflight validation) + Non-interactive flags: - `--auth-choice custom-api-key` - `--custom-base-url` @@ -202,6 +206,24 @@ Credential and profile paths: - OAuth credentials: `~/.openclaw/credentials/oauth.json` - Auth profiles (API keys + OAuth): `~/.openclaw/agents//agent/auth-profiles.json` +API key storage mode: + +- Default onboarding behavior persists API keys as plaintext values in auth profiles. +- `--secret-input-mode ref` enables reference mode instead of plaintext key storage. + In interactive onboarding, you can choose either: + - environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`) + - configured provider ref (`file` or `exec`) with provider alias + id +- Interactive reference mode runs a fast preflight validation before saving. + - Env refs: validates variable name + non-empty value in the current onboarding environment. + - Provider refs: validates provider config and resolves the requested id. + - If preflight fails, onboarding shows the error and lets you retry. +- In non-interactive mode, `--secret-input-mode ref` is env-backed only. + - Set the provider env var in the onboarding process environment. + - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. + - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. + - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. +- Existing plaintext setups continue to work unchanged. + Headless and server tip: complete OAuth on a machine with a browser, then copy `~/.openclaw/credentials/oauth.json` (or `$OPENCLAW_STATE_DIR/credentials/oauth.json`) @@ -215,6 +237,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) - `gateway.*` (mode, bind, auth, tailscale) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible) - `skills.install.nodeManager` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index b869c85665fc..ecf059c3b89d 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) + - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) @@ -64,6 +65,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). 1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider (OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model. + For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values. + In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast. + In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving. 2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files. 3. **Gateway** — Port, bind address, auth mode, Tailscale exposure. 4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage. @@ -73,6 +77,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`). +CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace. If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md new file mode 100644 index 000000000000..6ae43de9fd0f --- /dev/null +++ b/docs/tools/acp-agents.md @@ -0,0 +1,346 @@ +--- +summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents" +read_when: + - Running coding harnesses through ACP + - Setting up thread-bound ACP sessions on thread-capable channels + - Troubleshooting ACP backend and plugin wiring + - Operating /acp commands from chat +title: "ACP Agents" +--- + +# ACP agents + +ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin. + +If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime). + +## Fast operator flow + +Use this when you want a practical `/acp` runbook: + +1. Spawn a session: + - `/acp spawn codex --mode persistent --thread auto` +2. Work in the bound thread (or target that session key explicitly). +3. Check runtime state: + - `/acp status` +4. Tune runtime options as needed: + - `/acp model ` + - `/acp permissions ` + - `/acp timeout ` +5. Nudge an active session without replacing context: + - `/acp steer tighten logging and continue` +6. Stop work: + - `/acp cancel` (stop current turn), or + - `/acp close` (close session + remove bindings) + +## Quick start for humans + +Examples of natural requests: + +- "Start a persistent Codex session in a thread here and keep it focused." +- "Run this as a one-shot Claude Code ACP session and summarize the result." +- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread." + +What OpenClaw should do: + +1. Pick `runtime: "acp"`. +2. Resolve the requested harness target (`agentId`, for example `codex`). +3. If thread binding is requested and the current channel supports it, bind the ACP session to the thread. +4. Route follow-up thread messages to that same ACP session until unfocused/closed/expired. + +## ACP versus sub-agents + +Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs. + +| Area | ACP session | Sub-agent run | +| ------------- | ------------------------------------- | ---------------------------------- | +| Runtime | ACP backend plugin (for example acpx) | OpenClaw native sub-agent runtime | +| Session key | `agent::acp:` | `agent::subagent:` | +| Main commands | `/acp ...` | `/subagents ...` | +| Spawn tool | `sessions_spawn` with `runtime:"acp"` | `sessions_spawn` (default runtime) | + +See also [Sub-agents](/tools/subagents). + +## Thread-bound sessions (channel-agnostic) + +When thread bindings are enabled for a channel adapter, ACP sessions can be bound to threads: + +- OpenClaw binds a thread to a target ACP session. +- Follow-up messages in that thread route to the bound ACP session. +- ACP output is delivered back to the same thread. +- Unfocus/close/archive/TTL expiry removes the binding. + +Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message. + +Required feature flags for thread-bound ACP: + +- `acp.enabled=true` +- `acp.dispatch.enabled=true` +- Channel-adapter ACP thread-spawn flag enabled (adapter-specific) + - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + +### Thread supporting channels + +- Any channel adapter that exposes session/thread binding capability. +- Current built-in support: Discord. +- Plugin channels can add support through the same binding interface. + +## Start ACP sessions (interfaces) + +### From `sessions_spawn` + +Use `runtime: "acp"` to start an ACP session from an agent turn or tool call. + +```json +{ + "task": "Open the repo and summarize failing tests", + "runtime": "acp", + "agentId": "codex", + "thread": true, + "mode": "session" +} +``` + +Notes: + +- `runtime` defaults to `subagent`, so set `runtime: "acp"` explicitly for ACP sessions. +- If `agentId` is omitted, OpenClaw uses `acp.defaultAgent` when configured. +- `mode: "session"` requires `thread: true` to keep a persistent bound conversation. + +Interface details: + +- `task` (required): initial prompt sent to the ACP session. +- `runtime` (required for ACP): must be `"acp"`. +- `agentId` (optional): ACP target harness id. Falls back to `acp.defaultAgent` if set. +- `thread` (optional, default `false`): request thread binding flow where supported. +- `mode` (optional): `run` (one-shot) or `session` (persistent). + - default is `run` + - if `thread: true` and mode omitted, OpenClaw may default to persistent behavior per runtime path + - `mode: "session"` requires `thread: true` +- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy). +- `label` (optional): operator-facing label used in session/banner text. + +### From `/acp` command + +Use `/acp spawn` for explicit operator control from chat when needed. + +```text +/acp spawn codex --mode persistent --thread auto +/acp spawn codex --mode oneshot --thread off +/acp spawn codex --thread here +``` + +Key flags: + +- `--mode persistent|oneshot` +- `--thread auto|here|off` +- `--cwd ` +- `--label ` + +See [Slash Commands](/tools/slash-commands). + +## Session target resolution + +Most `/acp` actions accept an optional session target (`session-key`, `session-id`, or `session-label`). + +Resolution order: + +1. Explicit target argument (or `--session` for `/acp steer`) + - tries key + - then UUID-shaped session id + - then label +2. Current thread binding (if this conversation/thread is bound to an ACP session) +3. Current requester session fallback + +If no target resolves, OpenClaw returns a clear error (`Unable to resolve session target: ...`). + +## Spawn thread modes + +`/acp spawn` supports `--thread auto|here|off`. + +| Mode | Behavior | +| ------ | --------------------------------------------------------------------------------------------------- | +| `auto` | In an active thread: bind that thread. Outside a thread: create/bind a child thread when supported. | +| `here` | Require current active thread; fail if not in one. | +| `off` | No binding. Session starts unbound. | + +Notes: + +- On non-thread binding surfaces, default behavior is effectively `off`. +- Thread-bound spawn requires channel policy support (for Discord: `channels.discord.threadBindings.spawnAcpSessions=true`). + +## ACP controls + +Available command family: + +- `/acp spawn` +- `/acp cancel` +- `/acp steer` +- `/acp close` +- `/acp status` +- `/acp set-mode` +- `/acp set` +- `/acp cwd` +- `/acp permissions` +- `/acp timeout` +- `/acp model` +- `/acp reset-options` +- `/acp sessions` +- `/acp doctor` +- `/acp install` + +`/acp status` shows the effective runtime options and, when available, both runtime-level and backend-level session identifiers. + +Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error. + +## ACP command cookbook + +| Command | What it does | Example | +| -------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| `/acp spawn` | Create ACP session; optional thread bind. | `/acp spawn codex --mode persistent --thread auto --cwd /repo` | +| `/acp cancel` | Cancel in-flight turn for target session. | `/acp cancel agent:codex:acp:` | +| `/acp steer` | Send steer instruction to running session. | `/acp steer --session support inbox prioritize failing tests` | +| `/acp close` | Close session and unbind thread targets. | `/acp close` | +| `/acp status` | Show backend, mode, state, runtime options, capabilities. | `/acp status` | +| `/acp set-mode` | Set runtime mode for target session. | `/acp set-mode plan` | +| `/acp set` | Generic runtime config option write. | `/acp set model openai/gpt-5.2` | +| `/acp cwd` | Set runtime working directory override. | `/acp cwd /Users/user/Projects/repo` | +| `/acp permissions` | Set approval policy profile. | `/acp permissions strict` | +| `/acp timeout` | Set runtime timeout (seconds). | `/acp timeout 120` | +| `/acp model` | Set runtime model override. | `/acp model anthropic/claude-opus-4-5` | +| `/acp reset-options` | Remove session runtime option overrides. | `/acp reset-options` | +| `/acp sessions` | List recent ACP sessions from store. | `/acp sessions` | +| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` | +| `/acp install` | Print deterministic install and enable steps. | `/acp install` | + +## Runtime options mapping + +`/acp` has convenience commands and a generic setter. + +Equivalent operations: + +- `/acp model ` maps to runtime config key `model`. +- `/acp permissions ` maps to runtime config key `approval_policy`. +- `/acp timeout ` maps to runtime config key `timeout`. +- `/acp cwd ` updates runtime cwd override directly. +- `/acp set ` is the generic path. + - Special case: `key=cwd` uses the cwd override path. +- `/acp reset-options` clears all runtime overrides for target session. + +## acpx harness support (current) + +Current acpx built-in harness aliases: + +- `pi` +- `claude` +- `codex` +- `opencode` +- `gemini` + +When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases. + +Direct acpx CLI usage can also target arbitrary adapters via `--agent `, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path). + +## Required config + +Core ACP baseline: + +```json5 +{ + acp: { + enabled: true, + dispatch: { enabled: true }, + backend: "acpx", + defaultAgent: "codex", + allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"], + maxConcurrentSessions: 8, + stream: { + coalesceIdleMs: 300, + maxChunkChars: 1200, + }, + runtime: { + ttlMinutes: 120, + }, + }, +} +``` + +Thread binding config is channel-adapter specific. Example for Discord: + +```json5 +{ + session: { + threadBindings: { + enabled: true, + ttlHours: 24, + }, + }, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, +} +``` + +If thread-bound ACP spawn does not work, verify the adapter feature flag first: + +- Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + +See [Configuration Reference](/gateway/configuration-reference). + +## Plugin setup for acpx backend + +Install and enable plugin: + +```bash +openclaw plugins install @openclaw/acpx +openclaw config set plugins.entries.acpx.enabled true +``` + +Local workspace install during development: + +```bash +openclaw plugins install ./extensions/acpx +``` + +Then verify backend health: + +```text +/acp doctor +``` + +### Pinned acpx install strategy (current behavior) + +`@openclaw/acpx` now enforces a strict plugin-local pinning model: + +1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`. +2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`. +3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked. +4. Startup registers the ACP backend immediately as not-ready. +5. A background ensure job verifies `acpx --version` against the pinned version. +6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@`) and re-verifies before healthy. + +Notes: + +- OpenClaw startup stays non-blocking while acpx ensure runs. +- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix. + +See [Plugins](/tools/plugin). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| ----------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------- | +| `ACP runtime backend is not configured` | Backend plugin missing or disabled. | Install and enable backend plugin, then run `/acp doctor`. | +| `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. | +| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true`. | +| `ACP agent "" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. | +| `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. | +| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. | +| `Only can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. | +| `Thread bindings are unavailable for .` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. | +| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. | diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4d8492f2151c..13eaf3203f84 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -59,6 +59,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. { browser: { enabled: true, // default: true + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) @@ -86,6 +92,9 @@ Notes: - `cdpUrl` defaults to the relay port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. +- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing. +- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. - Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser. @@ -561,6 +570,20 @@ These are useful for “make the site behave like X” workflows: - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. +Strict-mode example (block private/internal destinations by default): + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], // optional exact allow + }, + }, +} +``` + ## Troubleshooting For Linux-specific issues (especially snap Chromium), see diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a73..964eb40f37b5 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -77,6 +77,18 @@ openclaw browser create-profile \ --color "#00AA00" ``` +### Custom Gateway ports + +If you're using a custom gateway port, the extension relay port is automatically derived: + +**Extension Relay Port = Gateway Port + 3** + +Example: if `gateway.port: 19001`, then: + +- Extension relay port: `19004` (gateway + 3) + +Configure the extension to use the derived relay port in the extension Options page. + ## Attach / detach (toolbar button) - Open the tab you want OpenClaw to control. diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index 0a6f2fd692bc..964165ad0a26 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -1,5 +1,9 @@ --- title: "Creating Skills" +summary: "Build and test custom workspace skills with SKILL.md" +read_when: + - You are creating a new custom skill in your workspace + - You need a quick starter workflow for SKILL.md-based skills --- # Creating Custom Skills 🛠 diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index c9b8d87a9495..eed788eda8c5 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -46,6 +46,12 @@ title: "Elevated Mode" - Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). - Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). +- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization. +- Mutable sender metadata requires explicit prefixes: + - `name:` matches `SenderName` + - `username:` matches `SenderUsername` + - `tag:` matches `SenderTag` + - `id:`, `from:`, `e164:` are available for explicit identity targeting - Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict). - Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists). - Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index f977952c83c4..619f5cdb38e5 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -25,6 +25,12 @@ Exec approvals are enforced locally on the execution host: - **gateway host** → `openclaw` process on the gateway machine - **node host** → node runner (macOS companion app or headless node host) +Trust model note: + +- Gateway-authenticated callers are trusted operators for that Gateway. +- Paired nodes extend that trusted operator capability onto the node host. +- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary. + macOS split: - **node host service** forwards `system.run` to the **macOS app** over local IPC. @@ -119,33 +125,50 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill are treated as allowlisted on nodes (macOS node or headless node host). This uses `skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists. +Important trust notes: + +- This is an **implicit convenience allowlist**, separate from manual path allowlist entries. +- It is intended for trusted operator environments where Gateway and node are in the same trust boundary. +- If you require strict explicit trust, keep `autoAllowSkills: false` and use manual path allowlist entries only. + ## Safe bins (stdin-only) `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject positional file args and path-like tokens, so they can only operate on the incoming stream. +Treat this as a narrow fast-path for stream filters, not a general trust list. +Do **not** add interpreter or runtime binaries (for example `python3`, `node`, `ruby`, `bash`, `sh`, `zsh`) to `safeBins`. +If a command can evaluate code, execute subcommands, or read files by design, prefer explicit allowlist entries and keep approval prompts enabled. +Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles.`. Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, -`sort --files0-from`, `sort --compress-program`, `wc --files0-from`, `jq -f/--from-file`, +`sort --files0-from`, `sort --compress-program`, `sort --random-source`, +`sort --temporary-directory`/`-T`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`). Safe bins also enforce explicit per-binary flag policy for options that break stdin-only behavior (for example `sort -o/--output/--compress-program` and grep recursive flags). +Long options are validated fail-closed in safe-bin mode: unknown flags and ambiguous +abbreviations are rejected. Denied flags by safe-bin profile: - `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r` - `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f` -- `sort`: `--compress-program`, `--files0-from`, `--output`, `-o` +- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o` - `wc`: `--files0-from` Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be used to smuggle file reads. -Safe bins must also resolve from trusted binary directories (system defaults plus the gateway -process `PATH` at startup). This blocks request-scoped PATH hijacking attempts. +Safe bins must also resolve from trusted binary directories (system defaults plus optional +`tools.exec.safeBinTrustedDirs`). `PATH` entries are never auto-trusted. +Default trusted safe-bin directories are intentionally minimal: `/bin`, `/usr/bin`. +If your safe-bin executable lives in package-manager/user paths (for example +`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/snap/bin`), add them explicitly +to `tools.exec.safeBinTrustedDirs`. Shell chaining and redirections are not auto-allowed in allowlist mode. Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist @@ -155,6 +178,13 @@ double quotes; use single quotes if you need literal `$()` text. On macOS companion-app approvals, raw shell text containing shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss unless the shell binary itself is allowlisted. +For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are reduced to a +small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). +For allow-always decisions in allowlist mode, known dispatch wrappers +(`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`, +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. @@ -163,6 +193,45 @@ their non-stdin workflows. For `grep` in safe-bin mode, provide the pattern with `-e`/`--regexp`; positional pattern form is rejected so file operands cannot be smuggled as ambiguous positionals. +### Safe bins versus allowlist + +| Topic | `tools.exec.safeBins` | Allowlist (`exec-approvals.json`) | +| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables | +| Match type | Executable name + safe-bin argv policy | Resolved executable path glob pattern | +| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match only; arguments are otherwise your responsibility | +| Typical examples | `jq`, `head`, `tail`, `wc` | `python3`, `node`, `ffmpeg`, custom CLIs | +| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects | + +Configuration location: + +- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). +- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`). +- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. +- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). +- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. +- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded. + +Custom profile example: + +```json5 +{ + tools: { + exec: { + safeBins: ["jq", "myfilter"], + safeBinProfiles: { + myfilter: { + minPositional: 0, + maxPositional: 0, + allowedValueFlags: ["-n", "--limit"], + deniedFlags: ["-f", "--file", "-c", "--command"], + }, + }, + }, + }, +} +``` + ## Control UI editing Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 37994031a6b6..822717fcf382 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -36,11 +36,13 @@ Notes: - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`) from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists. +- On Windows hosts, exec prefers PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, then PATH), + then falls back to Windows PowerShell 5.1. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. -- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on - the gateway host (no container) and **does not require approvals**. To require approvals, run with - `host=gateway` and configure exec approvals (or enable sandboxing). +- Important: sandboxing is **off by default**. If sandboxing is off and `host=sandbox` is explicitly + configured/requested, exec now fails closed instead of silently running on the gateway host. + Enable sandboxing or use `host=gateway` with approvals. - Script preflight checks (for common Python/Node shell-syntax mistakes) only inspect files inside the effective `workdir` boundary. If a script path resolves outside `workdir`, preflight is skipped for that file. @@ -55,6 +57,8 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only). - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only). +- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. Built-in defaults are `/bin` and `/usr/bin`. +- `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). Example: @@ -120,12 +124,27 @@ running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` noti ## Allowlist + safe bins -Allowlist enforcement matches **resolved binary paths only** (no basename matches). When +Manual allowlist enforcement matches **resolved binary paths only** (no basename matches). When `security=allowlist`, shell commands are auto-allowed only if every pipeline segment is allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in allowlist mode unless every top-level segment satisfies the allowlist (including safe bins). Redirections remain unsupported. +`autoAllowSkills` is a separate convenience path in exec approvals. It is not the same as +manual path allowlist entries. For strict explicit trust, keep `autoAllowSkills` disabled. + +Use the two controls for different jobs: + +- `tools.exec.safeBins`: small, stdin-only stream filters. +- `tools.exec.safeBinTrustedDirs`: explicit extra trusted directories for safe-bin executable paths. +- `tools.exec.safeBinProfiles`: explicit argv policy for custom safe bins. +- allowlist: explicit trust for executable paths. + +Do not treat `safeBins` as a generic allowlist, and do not add interpreter/runtime binaries (for example `python3`, `node`, `ruby`, `bash`). If you need those, use explicit allowlist entries and keep approval prompts enabled. +`openclaw security audit` warns when interpreter/runtime `safeBins` entries are missing explicit profiles, and `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles` entries. + +For full policy details and examples, see [Exec approvals](/tools/exec-approvals#safe-bins-stdin-only) and [Safe bins versus allowlist](/tools/exec-approvals#safe-bins-versus-allowlist). + ## Examples Foreground: diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccdb..fa35a63cb7bd 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -464,7 +464,7 @@ Core parameters: - `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) - `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` - `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?` +- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?` - `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) Notes: @@ -474,10 +474,12 @@ Notes: - Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing. - `sessions_send` waits for final completion when `timeoutSeconds > 0`. - Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. +- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents). - `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - If `thread: true` and `mode` is omitted, mode defaults to `session`. - `mode: "session"` requires `thread: true`. + - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - Reply format includes `Status`, `Result`, and compact stats. - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md index 440047e8aa6d..f41eeb0851bd 100644 --- a/docs/tools/loop-detection.md +++ b/docs/tools/loop-detection.md @@ -1,6 +1,7 @@ --- title: "Tool-loop detection" description: "Configure optional guardrails for preventing repetitive or stalled tool-call loops" +summary: "How to enable and tune guardrails that detect repetitive tool-call loops" read_when: - A user reports agents getting stuck repeating tool calls - You need to tune repetitive-call protection diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 86a2b984316a..3dc575088eb9 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -330,22 +330,29 @@ Plugins export either: ## Plugin hooks -Plugins can ship hooks and register them at runtime. This lets a plugin bundle -event-driven automation without a separate hook pack install. +Plugins can register hooks at runtime. This lets a plugin bundle event-driven +automation without a separate hook pack install. ### Example -``` -import { registerPluginHooksFromDir } from "openclaw/plugin-sdk"; - +```ts export default function register(api) { - registerPluginHooksFromDir(api, "./hooks"); + api.registerHook( + "command:new", + async () => { + // Hook logic here. + }, + { + name: "my-plugin.command-new", + description: "Runs when /new is invoked", + }, + ); } ``` Notes: -- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`). +- Register hooks explicitly via `api.registerHook(...)`. - Hook eligibility rules still apply (OS/bins/env/config requirements). - Plugin-managed hooks show up in `openclaw hooks list` with `plugin:`. - You cannot enable/disable plugin-managed hooks via `openclaw hooks`; enable/disable the plugin instead. @@ -445,6 +452,29 @@ Notes: - `meta.preferOver` lists channel ids to skip auto-enable when both are configured. - `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons. +### Channel onboarding hooks + +Channel plugins can define optional onboarding hooks on `plugin.onboarding`: + +- `configure(ctx)` is the baseline setup flow. +- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states. +- `configureWhenConfigured(ctx)` can override behavior only for already configured channels. + +Hook precedence in the wizard: + +1. `configureInteractive` (if present) +2. `configureWhenConfigured` (only when channel status is already configured) +3. fallback to `configure` + +Context details: + +- `configureInteractive` and `configureWhenConfigured` receive: + - `configured` (`true` or `false`) + - `label` (user-facing channel name used by prompts) + - plus the shared config/runtime/prompter/options fields +- Returning `"skip"` leaves selection and account tracking unchanged. +- Returning `{ cfg, accountId? }` applies config updates and records account selection. + ### Write a new messaging channel (step‑by‑step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index d4d666ec198a..589d464bb13c 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -26,7 +26,7 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j entries: { "nano-banana-pro": { enabled: true, - apiKey: "GEMINI_KEY_HERE", + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { GEMINI_API_KEY: "GEMINI_KEY_HERE", }, @@ -56,6 +56,7 @@ Per-skill fields: - `enabled`: set `false` to disable a skill even if it’s bundled/installed. - `env`: environment variables injected for the agent run (only if not already set). - `apiKey`: optional convenience for skills that declare a primary env var. + Supports plaintext string or SecretRef object (`{ source, provider, id }`). ## Notes diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 1e5fa2c50487..de3fe807ed25 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -195,7 +195,7 @@ Bundled/managed skills can be toggled and supplied with env values: entries: { "nano-banana-pro": { enabled: true, - apiKey: "GEMINI_KEY_HERE", + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { GEMINI_API_KEY: "GEMINI_KEY_HERE", }, @@ -221,6 +221,7 @@ Rules: - `enabled: false` disables the skill even if it’s bundled/installed. - `env`: injected **only if** the variable isn’t already set in the process. - `apiKey`: convenience for skills that declare `metadata.openclaw.primaryEnv`. + Supports plaintext string or SecretRef object (`{ source, provider, id }`). - `config`: optional bag for custom per-skill fields; custom keys must live here. - `allowBundled`: optional allowlist for **bundled** skills only. If set, only bundled skills in the list are eligible (managed/workspace skills unaffected). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 7d9bb6166420..4d045d4ee716 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -80,6 +80,7 @@ Text + native (when enabled): - `/whoami` (show your sender id; alias: `/id`) - `/session ttl ` (manage session-level settings, such as TTL) - `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session) +- `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions) - `/agents` (list thread-bound agents for this session) - `/focus ` (Discord: bind this thread, or a new thread, to a session/subagent target) - `/unfocus` (Discord: remove the current thread binding) @@ -125,7 +126,9 @@ Notes: - `/restart` is enabled by default; set `commands.restart: false` to disable it. - Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text). - Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). +- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. +- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). - **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 5c2549e44268..8d066a94e7f3 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -23,7 +23,9 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session* - `/subagents steer ` - `/subagents spawn [--model ] [--thinking ]` -Discord thread binding controls: +Thread binding controls: + +These commands work on channels that support persistent thread bindings. See **Thread supporting channels** below. - `/focus ` - `/unfocus` @@ -49,6 +51,7 @@ Discord thread binding controls: - `--model` and `--thinking` override defaults for that specific run. - Use `info`/`log` to inspect details and output after completion. - `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`. +- For ACP harness sessions (Codex, Claude Code, Gemini CLI), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents). Primary goals: @@ -69,6 +72,7 @@ Use `sessions_spawn`: - Then runs an announce step and posts the announce reply to the requester chat channel - Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. - Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- Default run timeout: if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). Tool params: @@ -77,7 +81,7 @@ Tool params: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, the sub-agent run is aborted after N seconds) - `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session) - `mode?` (`run|session`) - default is `run` @@ -85,14 +89,18 @@ Tool params: - `mode: "session"` requires `thread: true` - `cleanup?` (`delete|keep`, default `keep`) -## Discord thread-bound sessions +## Thread-bound sessions + +When thread bindings are enabled for a channel, a sub-agent can stay bound to a thread so follow-up user messages in that thread keep routing to the same sub-agent session. + +### Thread supporting channels -When thread bindings are enabled, a sub-agent can stay bound to a Discord thread so follow-up user messages in that thread keep routing to the same sub-agent session. +- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`. Quick flow: 1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`). -2. OpenClaw creates or binds a Discord thread to that session target. +2. OpenClaw creates or binds a thread to that session target in the active channel. 3. Replies and follow-up messages in that thread route to the bound session. 4. Use `/session ttl` to inspect/update auto-unfocus TTL. 5. Use `/unfocus` to detach manually. @@ -100,17 +108,16 @@ Quick flow: Manual controls: - `/focus ` binds the current thread (or creates one) to a sub-agent/session target. -- `/unfocus` removes the binding for the current Discord thread. +- `/unfocus` removes the binding for the current bound thread. - `/agents` lists active runs and binding state (`thread:` or `unbound`). -- `/session ttl` only works for focused Discord threads. +- `/session ttl` only works for focused bound threads. Config switches: - Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours` -- Discord override: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours` -- Spawn auto-bind opt-in: `channels.discord.threadBindings.spawnSubagentSessions` +- Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above. -See [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), and [Slash commands](/tools/slash-commands). +See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details. Allowlist: @@ -143,6 +150,7 @@ By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). Y maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) + runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout) }, }, }, @@ -202,7 +210,7 @@ Sub-agents report back via an announce step: - The announce step runs inside the sub-agent session (not the requester session). - If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. - Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`). -- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads). +- Announce replies preserve thread/topic routing when available on channel adapters. - Announce messages are normalized to a stable template: - `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`). - `Result:` the summary content from the announce step (or `(not available)` if missing). diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index c01ea540f0e9..2cf55b6b12b9 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -47,6 +47,7 @@ title: "Thinking Levels" - Inline directive affects only that message; session/global defaults apply otherwise. - Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool call back as its own metadata-only message, prefixed with ` : ` when available (path/command). These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas. +- Tool failure summaries remain visible in normal mode, but raw error detail suffixes are hidden unless verbose is `on` or `full`. - When verbose is `full`, tool outputs are also forwarded after completion (separate bubble, truncated to a safe length). If you toggle `/verbose on|full|off` while a run is in-flight, subsequent tool bubbles honor the new setting. ## Reasoning visibility (/reasoning) diff --git a/docs/tools/web.md b/docs/tools/web.md index b0e295cd22a7..0d48d746b5e1 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,9 +1,10 @@ --- -summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" +summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)" read_when: - You want to enable web_search or web_fetch - You need Brave Search API key setup - You want to use Perplexity Sonar for web search + - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +12,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). +- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -22,6 +23,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_search` calls your configured provider and returns results. - **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity**: returns AI-synthesized answers with citations from real-time web search. + - **Gemini**: returns AI-synthesized answers grounded in Google Search with citations. - Results are cached by query for 15 minutes (configurable). - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. @@ -33,9 +35,23 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. +### Auto-detection + +If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: + +1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config +2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config +3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config +4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config + +If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). + +### Explicit provider + Set the provider in config: ```json5 @@ -43,7 +59,7 @@ Set the provider in config: tools: { web: { search: { - provider: "brave", // or "perplexity" + provider: "brave", // or "perplexity" or "gemini" }, }, }, @@ -139,6 +155,49 @@ If no base URL is set, OpenClaw chooses a default based on the API key source: | `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions | | `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research | +## Using Gemini (Google Search grounding) + +Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), +which returns AI-synthesized answers backed by live Google Search results with citations. + +### Getting a Gemini API key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create an API key +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` + +### Setting up Gemini search + +```json5 +{ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, +} +``` + +**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +### Notes + +- Citation URLs from Gemini grounding are automatically resolved from Google's + redirect URLs to direct URLs. +- Redirect resolution uses the SSRF guard path (HEAD + redirect checks + http/https validation) before returning the final citation URL. +- This redirect resolver follows the trusted-network model (private/internal networks allowed by default) to match Gateway operator trust assumptions. +- The default model (`gemini-2.5-flash`) is fast and cost-effective. + Any Gemini model that supports grounding can be used. + ## web_search Search the web using your configured provider. diff --git a/docs/vps.md b/docs/vps.md index f0b1f7d77775..adb884038909 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -34,6 +34,16 @@ deployments work at a high level. Remote access: [Gateway remote](/gateway/remote) Platforms hub: [Platforms](/platforms) +## Shared company agent on a VPS + +This is a valid setup when the users are in one trust boundary (for example one company team), and the agent is business-only. + +- Keep it on a dedicated runtime (VPS/VM/container + dedicated OS user/accounts). +- Do not sign that runtime into personal Apple/Google accounts or personal browser/password-manager profiles. +- If users are adversarial to each other, split by gateway/host/OS user. + +Security model details: [Security](/gateway/security) + ## Using nodes with a VPS You can keep the Gateway in the cloud and pair **nodes** on your local devices diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0a..ad6d2393523a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. @@ -96,7 +99,7 @@ Cron jobs panel notes: - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band + - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: - When a run is aborted, partial assistant text can still be shown in the UI @@ -230,8 +233,10 @@ Notes: Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. -- For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI - origin to `gateway.controlUi.allowedOrigins`. +- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` + explicitly (full origins). This includes remote dev setups. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but it is a dangerous security mode. Example: diff --git a/docs/web/index.md b/docs/web/index.md index 42baffe8027e..3fc48dd993c1 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -99,8 +99,10 @@ Open: - Non-loopback binds still **require** a shared token/password (`gateway.auth` or env). - The wizard generates a gateway token by default (even on loopback). - The UI sends `connect.params.auth.token` or `connect.params.auth.password`. -- The Control UI sends anti-clickjacking headers and only accepts same-origin browser - websocket connections unless `gateway.controlUi.allowedOrigins` is set. +- For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` + explicitly (full origins). Without it, gateway startup is refused by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but is a dangerous security downgrade. - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth when `gateway.auth.allowTailscale` is `true` (no token/password required). HTTP API endpoints still require token/password. Set diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9853e372159d..307a69a8dcf3 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -31,6 +31,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. +## Control UI agents tools panel + +- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each + tool as `core` or `plugin:` (plus `optional` for optional plugin tools). +- If `tools.catalog` is unavailable, the panel falls back to a built-in static list. +- The panel edits profile and override config, but effective runtime access still follows policy + precedence (`allow`/`deny`, per-agent and provider/channel overrides). + ## Remote use - Remote mode tunnels the gateway WebSocket over SSH/Tailscale. diff --git a/extensions/acpx/index.ts b/extensions/acpx/index.ts new file mode 100644 index 000000000000..5f57e396f801 --- /dev/null +++ b/extensions/acpx/index.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { createAcpxPluginConfigSchema } from "./src/config.js"; +import { createAcpxRuntimeService } from "./src/service.js"; + +const plugin = { + id: "acpx", + name: "ACPX Runtime", + description: "ACP runtime backend powered by the acpx CLI.", + configSchema: createAcpxPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerService( + createAcpxRuntimeService({ + pluginConfig: api.pluginConfig, + }), + ); + }, +}; + +export default plugin; diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json new file mode 100644 index 000000000000..61790e6ca05d --- /dev/null +++ b/extensions/acpx/openclaw.plugin.json @@ -0,0 +1,55 @@ +{ + "id": "acpx", + "name": "ACPX Runtime", + "description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.", + "skills": ["./skills"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "cwd": { + "type": "string" + }, + "permissionMode": { + "type": "string", + "enum": ["approve-all", "approve-reads", "deny-all"] + }, + "nonInteractivePermissions": { + "type": "string", + "enum": ["deny", "fail"] + }, + "timeoutSeconds": { + "type": "number", + "minimum": 0.001 + }, + "queueOwnerTtlSeconds": { + "type": "number", + "minimum": 0 + } + } + }, + "uiHints": { + "cwd": { + "label": "Default Working Directory", + "help": "Default cwd for ACP session operations when not set per session." + }, + "permissionMode": { + "label": "Permission Mode", + "help": "Default acpx permission policy for runtime prompts." + }, + "nonInteractivePermissions": { + "label": "Non-Interactive Permission Policy", + "help": "acpx policy when interactive permission prompts are unavailable." + }, + "timeoutSeconds": { + "label": "Prompt Timeout Seconds", + "help": "Optional acpx timeout for each runtime turn.", + "advanced": true + }, + "queueOwnerTtlSeconds": { + "label": "Queue Owner TTL Seconds", + "help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.", + "advanced": true + } + } +} diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json new file mode 100644 index 000000000000..503748e37986 --- /dev/null +++ b/extensions/acpx/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/acpx", + "version": "2026.2.26", + "description": "OpenClaw ACP runtime backend via acpx", + "type": "module", + "dependencies": { + "acpx": "^0.1.13" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md new file mode 100644 index 000000000000..c80978fa8ae6 --- /dev/null +++ b/extensions/acpx/skills/acp-router/SKILL.md @@ -0,0 +1,209 @@ +--- +name: acp-router +description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). +user-invocable: false +--- + +# ACP Harness Router + +When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows. + +## Intent detection + +Trigger this skill when the user asks OpenClaw to: + +- run something in Pi / Claude Code / Codex / OpenCode / Gemini +- continue existing harness work +- relay instructions to an external coding harness +- keep an external harness conversation in a thread-like conversation + +## Mode selection + +Choose one of these paths: + +1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools. +2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly. + +Use direct `acpx` when one of these is true: + +- user explicitly asks for direct `acpx` driving +- ACP runtime/plugin path is unavailable or unhealthy +- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed + +Do not use: + +- `subagents` runtime for harness control +- `/acp` command delegation as a requirement for the user +- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available + +## AgentId mapping + +Use these defaults when user names a harness directly: + +- "pi" -> `agentId: "pi"` +- "claude" or "claude code" -> `agentId: "claude"` +- "codex" -> `agentId: "codex"` +- "opencode" -> `agentId: "opencode"` +- "gemini" or "gemini cli" -> `agentId: "gemini"` + +These defaults match current acpx built-in aliases. + +If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id. + +## OpenClaw ACP runtime path + +Required behavior: + +1. Use `sessions_spawn` with: + - `runtime: "acp"` + - `thread: true` + - `mode: "session"` (unless user explicitly wants one-shot) +2. Put requested work in `task` so the ACP session gets it immediately. +3. Set `agentId` explicitly unless ACP default agent is known. +4. Do not ask user to run slash commands or CLI when this path works directly. + +Example: + +User: "spawn a test codex session in thread and tell it to say hi" + +Call: + +```json +{ + "task": "Say hi.", + "runtime": "acp", + "agentId": "codex", + "thread": true, + "mode": "session" +} +``` + +## Thread spawn recovery policy + +When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end. + +Required behavior when ACP backend is unavailable: + +1. Do not immediately ask the user to pick an alternate path. +2. First attempt automatic local repair: + - ensure plugin-local pinned acpx is installed in `extensions/acpx` + - verify `${ACPX_CMD} --version` +3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user. +4. Retry ACP thread spawn once after repair. +5. Only if repair+retry fails, report the concrete error and then offer fallback options. + +When offering fallback, keep ACP first: + +- Option 1: retry ACP spawn after showing exact failing step +- Option 2: direct acpx telephone-game flow + +Do not default to subagent runtime for these requests. + +## ACPX install and version policy (direct acpx path) + +For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension. + +1. Prefer plugin-local binary, not global PATH: + - `./extensions/acpx/node_modules/.bin/acpx` +2. Resolve pinned version from extension dependency: + - `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"` +3. If binary is missing or version mismatched, install plugin-local pinned version: + - `cd extensions/acpx && npm install --omit=dev --no-save acpx@` +4. Verify before use: + - `./extensions/acpx/node_modules/.bin/acpx --version` +5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart. +6. Do not run `npm install -g acpx` unless the user explicitly asks for global install. + +Set and reuse: + +```bash +ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx" +``` + +## Direct acpx path ("telephone game") + +Use this path to drive harness sessions without `/acp` or subagent runtime. + +### Rules + +1. Use `exec` commands that call `${ACPX_CMD}`. +2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context. +3. Prefer `--format quiet` for clean assistant text to relay back to user. +4. Use `exec` (one-shot) only when the user wants one-shot behavior. +5. Keep working directory explicit (`--cwd`) when task scope depends on repo context. + +### Session naming + +Use a deterministic name, for example: + +- `oc--` + +Where `conversationId` is thread id when available, otherwise channel/conversation id. + +### Command templates + +Persistent session (create if missing, then prompt): + +```bash +${ACPX_CMD} codex sessions show oc-codex- \ + || ${ACPX_CMD} codex sessions new --name oc-codex- + +${ACPX_CMD} codex -s oc-codex- --cwd --format quiet "" +``` + +One-shot: + +```bash +${ACPX_CMD} codex exec --cwd --format quiet "" +``` + +Cancel in-flight turn: + +```bash +${ACPX_CMD} codex cancel -s oc-codex- +``` + +Close session: + +```bash +${ACPX_CMD} codex sessions close oc-codex- +``` + +### Harness aliases in acpx + +- `pi` +- `claude` +- `codex` +- `opencode` +- `gemini` + +### Built-in adapter commands in acpx + +Defaults are: + +- `pi -> npx pi-acp` +- `claude -> npx -y @zed-industries/claude-agent-acp` +- `codex -> npx @zed-industries/codex-acp` +- `opencode -> npx -y opencode-ai acp` +- `gemini -> gemini` + +If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults. + +### Failure handling + +- `acpx: command not found`: + - for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately + - restart gateway after install and offer to run the restart automatically + - then retry once + - do not ask for install permission first unless policy explicitly requires it + - do not install global `acpx` unless explicitly requested +- adapter command missing (for example `claude-agent-acp` not found): + - for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides + - then retry once before offering fallback + - if user wants binary-based overrides, install exactly the configured adapter binary +- `NO_SESSION`: run `${ACPX_CMD} sessions new --name ` then retry prompt. +- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired. + +### Output relay + +When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts new file mode 100644 index 000000000000..efd6d5c7e730 --- /dev/null +++ b/extensions/acpx/src/config.test.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ACPX_BUNDLED_BIN, + createAcpxPluginConfigSchema, + resolveAcpxPluginConfig, +} from "./config.js"; + +describe("acpx plugin config parsing", () => { + it("resolves a strict plugin-local acpx command", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + cwd: "/tmp/workspace", + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.command).toBe(ACPX_BUNDLED_BIN); + expect(resolved.cwd).toBe(path.resolve("/tmp/workspace")); + }); + + it("rejects command overrides", () => { + expect(() => + resolveAcpxPluginConfig({ + rawConfig: { + command: "acpx-custom", + }, + workspaceDir: "/tmp/workspace", + }), + ).toThrow("unknown config key: command"); + }); + + it("rejects commandArgs overrides", () => { + expect(() => + resolveAcpxPluginConfig({ + rawConfig: { + commandArgs: ["--foo"], + }, + workspaceDir: "/tmp/workspace", + }), + ).toThrow("unknown config key: commandArgs"); + }); + + it("schema rejects empty cwd", () => { + const schema = createAcpxPluginConfigSchema(); + if (!schema.safeParse) { + throw new Error("acpx config schema missing safeParse"); + } + const parsed = schema.safeParse({ cwd: " " }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts new file mode 100644 index 000000000000..bf5d0e0993ee --- /dev/null +++ b/extensions/acpx/src/config.ts @@ -0,0 +1,196 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk"; + +export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const; +export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number]; + +export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const; +export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number]; + +export const ACPX_PINNED_VERSION = "0.1.13"; +const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; +export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); +export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`; + +export type AcpxPluginConfig = { + cwd?: string; + permissionMode?: AcpxPermissionMode; + nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; + timeoutSeconds?: number; + queueOwnerTtlSeconds?: number; +}; + +export type ResolvedAcpxPluginConfig = { + command: string; + cwd: string; + permissionMode: AcpxPermissionMode; + nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; + timeoutSeconds?: number; + queueOwnerTtlSeconds: number; +}; + +const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads"; +const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail"; +const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1; + +type ParseResult = + | { ok: true; value: AcpxPluginConfig | undefined } + | { ok: false; message: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPermissionMode(value: string): value is AcpxPermissionMode { + return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode); +} + +function isNonInteractivePermissionPolicy( + value: string, +): value is AcpxNonInteractivePermissionPolicy { + return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy); +} + +function parseAcpxPluginConfig(value: unknown): ParseResult { + if (value === undefined) { + return { ok: true, value: undefined }; + } + if (!isRecord(value)) { + return { ok: false, message: "expected config object" }; + } + const allowedKeys = new Set([ + "cwd", + "permissionMode", + "nonInteractivePermissions", + "timeoutSeconds", + "queueOwnerTtlSeconds", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + return { ok: false, message: `unknown config key: ${key}` }; + } + } + + const cwd = value.cwd; + if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) { + return { ok: false, message: "cwd must be a non-empty string" }; + } + + const permissionMode = value.permissionMode; + if ( + permissionMode !== undefined && + (typeof permissionMode !== "string" || !isPermissionMode(permissionMode)) + ) { + return { + ok: false, + message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`, + }; + } + + const nonInteractivePermissions = value.nonInteractivePermissions; + if ( + nonInteractivePermissions !== undefined && + (typeof nonInteractivePermissions !== "string" || + !isNonInteractivePermissionPolicy(nonInteractivePermissions)) + ) { + return { + ok: false, + message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`, + }; + } + + const timeoutSeconds = value.timeoutSeconds; + if ( + timeoutSeconds !== undefined && + (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) + ) { + return { ok: false, message: "timeoutSeconds must be a positive number" }; + } + + const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds; + if ( + queueOwnerTtlSeconds !== undefined && + (typeof queueOwnerTtlSeconds !== "number" || + !Number.isFinite(queueOwnerTtlSeconds) || + queueOwnerTtlSeconds < 0) + ) { + return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" }; + } + + return { + ok: true, + value: { + cwd: typeof cwd === "string" ? cwd.trim() : undefined, + permissionMode: typeof permissionMode === "string" ? permissionMode : undefined, + nonInteractivePermissions: + typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined, + timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined, + queueOwnerTtlSeconds: + typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined, + }, + }; +} + +export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema { + return { + safeParse(value: unknown): + | { success: true; data?: unknown } + | { + success: false; + error: { issues: Array<{ path: Array; message: string }> }; + } { + const parsed = parseAcpxPluginConfig(value); + if (parsed.ok) { + return { success: true, data: parsed.value }; + } + return { + success: false, + error: { + issues: [{ path: [], message: parsed.message }], + }, + }; + }, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + cwd: { type: "string" }, + permissionMode: { + type: "string", + enum: [...ACPX_PERMISSION_MODES], + }, + nonInteractivePermissions: { + type: "string", + enum: [...ACPX_NON_INTERACTIVE_POLICIES], + }, + timeoutSeconds: { type: "number", minimum: 0.001 }, + queueOwnerTtlSeconds: { type: "number", minimum: 0 }, + }, + }, + }; +} + +export function resolveAcpxPluginConfig(params: { + rawConfig: unknown; + workspaceDir?: string; +}): ResolvedAcpxPluginConfig { + const parsed = parseAcpxPluginConfig(params.rawConfig); + if (!parsed.ok) { + throw new Error(parsed.message); + } + const normalized = parsed.value ?? {}; + const fallbackCwd = params.workspaceDir?.trim() || process.cwd(); + const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd); + + return { + command: ACPX_BUNDLED_BIN, + cwd, + permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, + nonInteractivePermissions: + normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, + timeoutSeconds: normalized.timeoutSeconds, + queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS, + }; +} diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts new file mode 100644 index 000000000000..0b36c3def365 --- /dev/null +++ b/extensions/acpx/src/ensure.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js"; + +const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({ + resolveSpawnFailureMock: vi.fn(() => null), + spawnAndCollectMock: vi.fn(), +})); + +vi.mock("./runtime-internals/process.js", () => ({ + resolveSpawnFailure: resolveSpawnFailureMock, + spawnAndCollect: spawnAndCollectMock, +})); + +import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js"; + +describe("acpx ensure", () => { + beforeEach(() => { + resolveSpawnFailureMock.mockReset(); + resolveSpawnFailureMock.mockReturnValue(null); + spawnAndCollectMock.mockReset(); + }); + + it("accepts the pinned acpx version", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: `acpx ${ACPX_PINNED_VERSION}\n`, + stderr: "", + code: 0, + error: null, + }); + + const result = await checkPinnedAcpxVersion({ + command: "/plugin/node_modules/.bin/acpx", + cwd: "/plugin", + expectedVersion: ACPX_PINNED_VERSION, + }); + + expect(result).toEqual({ + ok: true, + version: ACPX_PINNED_VERSION, + expectedVersion: ACPX_PINNED_VERSION, + }); + }); + + it("reports version mismatch", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }); + + const result = await checkPinnedAcpxVersion({ + command: "/plugin/node_modules/.bin/acpx", + cwd: "/plugin", + expectedVersion: ACPX_PINNED_VERSION, + }); + + expect(result).toMatchObject({ + ok: false, + reason: "version-mismatch", + expectedVersion: ACPX_PINNED_VERSION, + installedVersion: "0.0.9", + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }); + }); + + it("installs and verifies pinned acpx when precheck fails", async () => { + spawnAndCollectMock + .mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: "added 1 package\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: `acpx ${ACPX_PINNED_VERSION}\n`, + stderr: "", + code: 0, + error: null, + }); + + await ensurePinnedAcpx({ + command: "/plugin/node_modules/.bin/acpx", + pluginRoot: "/plugin", + expectedVersion: ACPX_PINNED_VERSION, + }); + + expect(spawnAndCollectMock).toHaveBeenCalledTimes(3); + expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ + command: "npm", + args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], + cwd: "/plugin", + }); + }); + + it("fails with actionable error when npm install fails", async () => { + spawnAndCollectMock + .mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: "", + stderr: "network down", + code: 1, + error: null, + }); + + await expect( + ensurePinnedAcpx({ + command: "/plugin/node_modules/.bin/acpx", + pluginRoot: "/plugin", + expectedVersion: ACPX_PINNED_VERSION, + }), + ).rejects.toThrow("failed to install plugin-local acpx"); + }); +}); diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts new file mode 100644 index 000000000000..6bb015587ae7 --- /dev/null +++ b/extensions/acpx/src/ensure.ts @@ -0,0 +1,169 @@ +import type { PluginLogger } from "openclaw/plugin-sdk"; +import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js"; +import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js"; + +const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/; + +export type AcpxVersionCheckResult = + | { + ok: true; + version: string; + expectedVersion: string; + } + | { + ok: false; + reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed"; + message: string; + expectedVersion: string; + installCommand: string; + installedVersion?: string; + }; + +function extractVersion(stdout: string, stderr: string): string | null { + const combined = `${stdout}\n${stderr}`; + const match = combined.match(SEMVER_PATTERN); + return match?.[0] ?? null; +} + +export async function checkPinnedAcpxVersion(params: { + command: string; + cwd?: string; + expectedVersion?: string; +}): Promise { + const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION; + const cwd = params.cwd ?? ACPX_PLUGIN_ROOT; + const result = await spawnAndCollect({ + command: params.command, + args: ["--version"], + cwd, + }); + + if (result.error) { + const spawnFailure = resolveSpawnFailure(result.error, cwd); + if (spawnFailure === "missing-command") { + return { + ok: false, + reason: "missing-command", + message: `acpx command not found at ${params.command}`, + expectedVersion, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }; + } + return { + ok: false, + reason: "execution-failed", + message: result.error.message, + expectedVersion, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }; + } + + if ((result.code ?? 0) !== 0) { + const stderr = result.stderr.trim(); + return { + ok: false, + reason: "execution-failed", + message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`, + expectedVersion, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }; + } + + const installedVersion = extractVersion(result.stdout, result.stderr); + if (!installedVersion) { + return { + ok: false, + reason: "missing-version", + message: "acpx --version output did not include a parseable version", + expectedVersion, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }; + } + + if (installedVersion !== expectedVersion) { + return { + ok: false, + reason: "version-mismatch", + message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`, + expectedVersion, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installedVersion, + }; + } + + return { + ok: true, + version: installedVersion, + expectedVersion, + }; +} + +let pendingEnsure: Promise | null = null; + +export async function ensurePinnedAcpx(params: { + command: string; + logger?: PluginLogger; + pluginRoot?: string; + expectedVersion?: string; +}): Promise { + if (pendingEnsure) { + return await pendingEnsure; + } + + pendingEnsure = (async () => { + const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT; + const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION; + + const precheck = await checkPinnedAcpxVersion({ + command: params.command, + cwd: pluginRoot, + expectedVersion, + }); + if (precheck.ok) { + return; + } + + params.logger?.warn( + `acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`, + ); + + const install = await spawnAndCollect({ + command: "npm", + args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`], + cwd: pluginRoot, + }); + + if (install.error) { + const spawnFailure = resolveSpawnFailure(install.error, pluginRoot); + if (spawnFailure === "missing-command") { + throw new Error("npm is required to install plugin-local acpx but was not found on PATH"); + } + throw new Error(`failed to install plugin-local acpx: ${install.error.message}`); + } + + if ((install.code ?? 0) !== 0) { + const stderr = install.stderr.trim(); + const stdout = install.stdout.trim(); + const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`; + throw new Error(`failed to install plugin-local acpx: ${detail}`); + } + + const postcheck = await checkPinnedAcpxVersion({ + command: params.command, + cwd: pluginRoot, + expectedVersion, + }); + + if (!postcheck.ok) { + throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`); + } + + params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`); + })(); + + try { + await pendingEnsure; + } finally { + pendingEnsure = null; + } +} diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts new file mode 100644 index 000000000000..074787b3fdff --- /dev/null +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -0,0 +1,140 @@ +import type { AcpRuntimeEvent } from "openclaw/plugin-sdk"; +import { + asOptionalBoolean, + asOptionalString, + asString, + asTrimmedString, + type AcpxErrorEvent, + type AcpxJsonObject, + isRecord, +} from "./shared.js"; + +export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null { + if (!isRecord(value)) { + return null; + } + if (asTrimmedString(value.type) !== "error") { + return null; + } + return { + message: asTrimmedString(value.message) || "acpx reported an error", + code: asOptionalString(value.code), + retryable: asOptionalBoolean(value.retryable), + }; +} + +export function parseJsonLines(value: string): AcpxJsonObject[] { + const events: AcpxJsonObject[] = []; + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isRecord(parsed)) { + events.push(parsed); + } + } catch { + // Ignore malformed lines; callers handle missing typed events via exit code. + } + } + return events; +} + +export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return { + type: "status", + text: trimmed, + }; + } + + if (!isRecord(parsed)) { + return null; + } + + const type = asTrimmedString(parsed.type); + switch (type) { + case "text": { + const content = asString(parsed.content); + if (content == null || content.length === 0) { + return null; + } + return { + type: "text_delta", + text: content, + stream: "output", + }; + } + case "thought": { + const content = asString(parsed.content); + if (content == null || content.length === 0) { + return null; + } + return { + type: "text_delta", + text: content, + stream: "thought", + }; + } + case "tool_call": { + const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool"; + const status = asTrimmedString(parsed.status); + return { + type: "tool_call", + text: status ? `${title} (${status})` : title, + }; + } + case "client_operation": { + const method = asTrimmedString(parsed.method) || "operation"; + const status = asTrimmedString(parsed.status); + const summary = asTrimmedString(parsed.summary); + const text = [method, status, summary].filter(Boolean).join(" "); + if (!text) { + return null; + } + return { type: "status", text }; + } + case "plan": { + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + const first = entries.find((entry) => isRecord(entry)) as Record | undefined; + const content = asTrimmedString(first?.content); + if (!content) { + return null; + } + return { type: "status", text: `plan: ${content}` }; + } + case "update": { + const update = asTrimmedString(parsed.update); + if (!update) { + return null; + } + return { type: "status", text: update }; + } + case "done": { + return { + type: "done", + stopReason: asOptionalString(parsed.stopReason), + }; + } + case "error": { + const message = asTrimmedString(parsed.message) || "acpx runtime error"; + return { + type: "error", + message, + code: asOptionalString(parsed.code), + retryable: asOptionalBoolean(parsed.retryable), + }; + } + default: + return null; + } +} diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts new file mode 100644 index 000000000000..752b48835ec7 --- /dev/null +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -0,0 +1,137 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +export type SpawnExit = { + code: number | null; + signal: NodeJS.Signals | null; + error: Error | null; +}; + +type ResolvedSpawnCommand = { + command: string; + args: string[]; + shell?: boolean; +}; + +function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand { + if (process.platform !== "win32") { + return { command: params.command, args: params.args }; + } + + const extension = path.extname(params.command).toLowerCase(); + if (extension === ".js" || extension === ".cjs" || extension === ".mjs") { + return { + command: process.execPath, + args: [params.command, ...params.args], + }; + } + + if (extension === ".cmd" || extension === ".bat") { + return { + command: params.command, + args: params.args, + shell: true, + }; + } + + return { + command: params.command, + args: params.args, + }; +} + +export function spawnWithResolvedCommand(params: { + command: string; + args: string[]; + cwd: string; +}): ChildProcessWithoutNullStreams { + const resolved = resolveSpawnCommand({ + command: params.command, + args: params.args, + }); + + return spawn(resolved.command, resolved.args, { + cwd: params.cwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + shell: resolved.shell, + }); +} + +export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + return await new Promise((resolve) => { + let settled = false; + const finish = (result: SpawnExit) => { + if (settled) { + return; + } + settled = true; + resolve(result); + }; + + child.once("error", (err) => { + finish({ code: null, signal: null, error: err }); + }); + + child.once("close", (code, signal) => { + finish({ code, signal, error: null }); + }); + }); +} + +export async function spawnAndCollect(params: { + command: string; + args: string[]; + cwd: string; +}): Promise<{ + stdout: string; + stderr: string; + code: number | null; + error: Error | null; +}> { + const child = spawnWithResolvedCommand(params); + child.stdin.end(); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + const exit = await waitForExit(child); + return { + stdout, + stderr, + code: exit.code, + error: exit.error, + }; +} + +export function resolveSpawnFailure( + err: unknown, + cwd: string, +): "missing-command" | "missing-cwd" | null { + if (!err || typeof err !== "object") { + return null; + } + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + return null; + } + return directoryExists(cwd) ? "missing-command" : "missing-cwd"; +} + +function directoryExists(cwd: string): boolean { + if (!cwd) { + return false; + } + try { + return existsSync(cwd); + } catch { + return false; + } +} diff --git a/extensions/acpx/src/runtime-internals/shared.ts b/extensions/acpx/src/runtime-internals/shared.ts new file mode 100644 index 000000000000..2f9b48025e66 --- /dev/null +++ b/extensions/acpx/src/runtime-internals/shared.ts @@ -0,0 +1,56 @@ +import type { ResolvedAcpxPluginConfig } from "../config.js"; + +export type AcpxHandleState = { + name: string; + agent: string; + cwd: string; + mode: "persistent" | "oneshot"; + acpxRecordId?: string; + backendSessionId?: string; + agentSessionId?: string; +}; + +export type AcpxJsonObject = Record; + +export type AcpxErrorEvent = { + message: string; + code?: string; + retryable?: boolean; +}; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asOptionalString(value: unknown): string | undefined { + const text = asTrimmedString(value); + return text || undefined; +} + +export function asOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string { + const match = sessionKey.match(/^agent:([^:]+):/i); + const candidate = match?.[1] ? asTrimmedString(match[1]) : ""; + return candidate || fallbackAgent; +} + +export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] { + if (mode === "approve-all") { + return ["--approve-all"]; + } + if (mode === "deny-all") { + return ["--deny-all"]; + } + return ["--approve-reads"]; +} diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts new file mode 100644 index 000000000000..d5e4fd275c79 --- /dev/null +++ b/extensions/acpx/src/runtime.test.ts @@ -0,0 +1,619 @@ +import fs from "node:fs"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js"; +import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js"; +import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js"; + +const NOOP_LOGGER = { + info: (_message: string) => {}, + warn: (_message: string) => {}, + error: (_message: string) => {}, + debug: (_message: string) => {}, +}; + +const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node +const fs = require("node:fs"); + +const args = process.argv.slice(2); +const logPath = process.env.MOCK_ACPX_LOG; +const writeLog = (entry) => { + if (!logPath) return; + fs.appendFileSync(logPath, JSON.stringify(entry) + "\n"); +}; + +if (args.includes("--version")) { + process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n"); + process.exit(0); +} + +if (args.includes("--help")) { + process.stdout.write("mock-acpx help\\n"); + process.exit(0); +} + +const commandIndex = args.findIndex( + (arg) => + arg === "prompt" || + arg === "cancel" || + arg === "sessions" || + arg === "set-mode" || + arg === "set" || + arg === "status", +); +const command = commandIndex >= 0 ? args[commandIndex] : ""; +const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown"; + +const readFlag = (flag) => { + const idx = args.indexOf(flag); + if (idx < 0) return ""; + return String(args[idx + 1] || ""); +}; + +const sessionFromOption = readFlag("--session"); +const ensureName = readFlag("--name"); +const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : ""; +const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : ""; +const setKey = command === "set" ? String(args[commandIndex + 1] || "") : ""; +const setValue = command === "set" ? String(args[commandIndex + 2] || "") : ""; + +if (command === "sessions" && args[commandIndex + 1] === "ensure") { + writeLog({ kind: "ensure", agent, args, sessionName: ensureName }); + process.stdout.write(JSON.stringify({ + type: "session_ensured", + acpxRecordId: "rec-" + ensureName, + acpxSessionId: "sid-" + ensureName, + agentSessionId: "inner-" + ensureName, + name: ensureName, + created: true, + }) + "\n"); + process.exit(0); +} + +if (command === "cancel") { + writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption }); + process.stdout.write(JSON.stringify({ + acpxSessionId: "sid-" + sessionFromOption, + cancelled: true, + }) + "\n"); + process.exit(0); +} + +if (command === "set-mode") { + writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue }); + process.stdout.write(JSON.stringify({ + type: "mode_set", + acpxSessionId: "sid-" + sessionFromOption, + mode: setModeValue, + }) + "\n"); + process.exit(0); +} + +if (command === "set") { + writeLog({ + kind: "set", + agent, + args, + sessionName: sessionFromOption, + key: setKey, + value: setValue, + }); + process.stdout.write(JSON.stringify({ + type: "config_set", + acpxSessionId: "sid-" + sessionFromOption, + key: setKey, + value: setValue, + }) + "\n"); + process.exit(0); +} + +if (command === "status") { + writeLog({ kind: "status", agent, args, sessionName: sessionFromOption }); + process.stdout.write(JSON.stringify({ + acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null, + acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null, + agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null, + status: sessionFromOption ? "alive" : "no-session", + pid: 4242, + uptime: 120, + }) + "\n"); + process.exit(0); +} + +if (command === "sessions" && args[commandIndex + 1] === "close") { + writeLog({ kind: "close", agent, args, sessionName: closeName }); + process.stdout.write(JSON.stringify({ + type: "session_closed", + acpxRecordId: "rec-" + closeName, + acpxSessionId: "sid-" + closeName, + name: closeName, + }) + "\n"); + process.exit(0); +} + +if (command === "prompt") { + const stdinText = fs.readFileSync(0, "utf8"); + writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText }); + const acpxSessionId = "sid-" + sessionFromOption; + + if (stdinText.includes("trigger-error")) { + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 0, + stream: "prompt", + type: "error", + code: "RUNTIME", + message: "mock failure", + }) + "\n"); + process.exit(1); + } + + if (stdinText.includes("split-spacing")) { + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 0, + stream: "prompt", + type: "text", + content: "alpha", + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 1, + stream: "prompt", + type: "text", + content: " beta", + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 2, + stream: "prompt", + type: "text", + content: " gamma", + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 3, + stream: "prompt", + type: "done", + stopReason: "end_turn", + }) + "\n"); + process.exit(0); + } + + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 0, + stream: "prompt", + type: "thought", + content: "thinking", + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 1, + stream: "prompt", + type: "tool_call", + title: "run-tests", + status: "in_progress", + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 2, + stream: "prompt", + type: "text", + content: "echo:" + stdinText.trim(), + }) + "\n"); + process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId, + requestId: "req-1", + seq: 3, + stream: "prompt", + type: "done", + stopReason: "end_turn", + }) + "\n"); + process.exit(0); +} + +writeLog({ kind: "unknown", args }); +process.stdout.write(JSON.stringify({ + eventVersion: 1, + acpxSessionId: "unknown", + seq: 0, + stream: "control", + type: "error", + code: "USAGE", + message: "unknown command", +}) + "\n"); +process.exit(2); +`; + +const tempDirs: string[] = []; + +async function createMockRuntime(params?: { + permissionMode?: ResolvedAcpxPluginConfig["permissionMode"]; + queueOwnerTtlSeconds?: number; +}): Promise<{ + runtime: AcpxRuntime; + logPath: string; + config: ResolvedAcpxPluginConfig; +}> { + const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-")); + tempDirs.push(dir); + const scriptPath = path.join(dir, "mock-acpx.cjs"); + const logPath = path.join(dir, "calls.log"); + await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8"); + await chmod(scriptPath, 0o755); + process.env.MOCK_ACPX_LOG = logPath; + + const config: ResolvedAcpxPluginConfig = { + command: scriptPath, + cwd: dir, + permissionMode: params?.permissionMode ?? "approve-all", + nonInteractivePermissions: "fail", + queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1, + }; + + return { + runtime: new AcpxRuntime(config, { + queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds, + logger: NOOP_LOGGER, + }), + logPath, + config, + }; +} + +async function readLogEntries(logPath: string): Promise>> { + if (!fs.existsSync(logPath)) { + return []; + } + const raw = await readFile(logPath, "utf8"); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +afterEach(async () => { + delete process.env.MOCK_ACPX_LOG; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) { + continue; + } + await rm(dir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 10, + }); + } +}); + +describe("AcpxRuntime", () => { + it("passes the shared ACP adapter contract suite", async () => { + const fixture = await createMockRuntime(); + await runAcpRuntimeAdapterContract({ + createRuntime: async () => fixture.runtime, + agentId: "codex", + successPrompt: "contract-pass", + errorPrompt: "trigger-error", + assertSuccessEvents: (events) => { + expect(events.some((event) => event.type === "done")).toBe(true); + }, + assertErrorOutcome: ({ events, thrown }) => { + expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true); + }, + }); + + const logs = await readLogEntries(fixture.logPath); + expect(logs.some((entry) => entry.kind === "ensure")).toBe(true); + expect(logs.some((entry) => entry.kind === "status")).toBe(true); + expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true); + expect(logs.some((entry) => entry.kind === "set")).toBe(true); + expect(logs.some((entry) => entry.kind === "cancel")).toBe(true); + expect(logs.some((entry) => entry.kind === "close")).toBe(true); + }); + + it("ensures sessions and streams prompt events", async () => { + const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 }); + + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:123", + agent: "codex", + mode: "persistent", + }); + expect(handle.backend).toBe("acpx"); + expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123"); + expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123"); + expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123"); + const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName); + expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123"); + expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123"); + expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123"); + + const events = []; + for await (const event of runtime.runTurn({ + handle, + text: "hello world", + mode: "prompt", + requestId: "req-test", + })) { + events.push(event); + } + + expect(events).toContainEqual({ + type: "text_delta", + text: "thinking", + stream: "thought", + }); + expect(events).toContainEqual({ + type: "tool_call", + text: "run-tests (in_progress)", + }); + expect(events).toContainEqual({ + type: "text_delta", + text: "echo:hello world", + stream: "output", + }); + expect(events).toContainEqual({ + type: "done", + stopReason: "end_turn", + }); + + const logs = await readLogEntries(logPath); + const ensure = logs.find((entry) => entry.kind === "ensure"); + const prompt = logs.find((entry) => entry.kind === "prompt"); + expect(ensure).toBeDefined(); + expect(prompt).toBeDefined(); + expect(Array.isArray(prompt?.args)).toBe(true); + const promptArgs = (prompt?.args as string[]) ?? []; + expect(promptArgs).toContain("--ttl"); + expect(promptArgs).toContain("180"); + expect(promptArgs).toContain("--approve-all"); + }); + + it("passes a queue-owner TTL by default to avoid long idle stalls", async () => { + const { runtime, logPath } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:ttl-default", + agent: "codex", + mode: "persistent", + }); + + for await (const _event of runtime.runTurn({ + handle, + text: "ttl-default", + mode: "prompt", + requestId: "req-ttl-default", + })) { + // drain + } + + const logs = await readLogEntries(logPath); + const prompt = logs.find((entry) => entry.kind === "prompt"); + expect(prompt).toBeDefined(); + const promptArgs = (prompt?.args as string[]) ?? []; + const ttlFlagIndex = promptArgs.indexOf("--ttl"); + expect(ttlFlagIndex).toBeGreaterThanOrEqual(0); + expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1"); + }); + + it("preserves leading spaces across streamed text deltas", async () => { + const { runtime } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:space", + agent: "codex", + mode: "persistent", + }); + + const textDeltas: string[] = []; + for await (const event of runtime.runTurn({ + handle, + text: "split-spacing", + mode: "prompt", + requestId: "req-space", + })) { + if (event.type === "text_delta" && event.stream === "output") { + textDeltas.push(event.text); + } + } + + expect(textDeltas).toEqual(["alpha", " beta", " gamma"]); + expect(textDeltas.join("")).toBe("alpha beta gamma"); + }); + + it("maps acpx error events into ACP runtime error events", async () => { + const { runtime } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:456", + agent: "codex", + mode: "persistent", + }); + + const events = []; + for await (const event of runtime.runTurn({ + handle, + text: "trigger-error", + mode: "prompt", + requestId: "req-err", + })) { + events.push(event); + } + + expect(events).toContainEqual({ + type: "error", + message: "mock failure", + code: "RUNTIME", + retryable: undefined, + }); + }); + + it("supports cancel and close using encoded runtime handle state", async () => { + const { runtime, logPath, config } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:claude:acp:789", + agent: "claude", + mode: "persistent", + }); + + const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName); + expect(decoded?.name).toBe("agent:claude:acp:789"); + + const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER }); + + await secondRuntime.cancel({ handle, reason: "test" }); + await secondRuntime.close({ handle, reason: "test" }); + + const logs = await readLogEntries(logPath); + const cancel = logs.find((entry) => entry.kind === "cancel"); + const close = logs.find((entry) => entry.kind === "close"); + expect(cancel?.sessionName).toBe("agent:claude:acp:789"); + expect(close?.sessionName).toBe("agent:claude:acp:789"); + }); + + it("exposes control capabilities and runs set-mode/set/status commands", async () => { + const { runtime, logPath } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:controls", + agent: "codex", + mode: "persistent", + }); + + const capabilities = runtime.getCapabilities(); + expect(capabilities.controls).toContain("session/set_mode"); + expect(capabilities.controls).toContain("session/set_config_option"); + expect(capabilities.controls).toContain("session/status"); + + await runtime.setMode({ + handle, + mode: "plan", + }); + await runtime.setConfigOption({ + handle, + key: "model", + value: "openai-codex/gpt-5.3-codex", + }); + const status = await runtime.getStatus({ handle }); + const ensuredSessionName = "agent:codex:acp:controls"; + + expect(status.summary).toContain("status=alive"); + expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName); + expect(status.backendSessionId).toBe("sid-" + ensuredSessionName); + expect(status.agentSessionId).toBe("inner-" + ensuredSessionName); + expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName); + expect(status.details?.status).toBe("alive"); + expect(status.details?.pid).toBe(4242); + + const logs = await readLogEntries(logPath); + expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan"); + expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model"); + expect(logs.find((entry) => entry.kind === "status")).toBeDefined(); + }); + + it("skips prompt execution when runTurn starts with an already-aborted signal", async () => { + const { runtime, logPath } = await createMockRuntime(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:aborted", + agent: "codex", + mode: "persistent", + }); + const controller = new AbortController(); + controller.abort(); + + const events = []; + for await (const event of runtime.runTurn({ + handle, + text: "should-not-run", + mode: "prompt", + requestId: "req-aborted", + signal: controller.signal, + })) { + events.push(event); + } + + const logs = await readLogEntries(logPath); + expect(events).toEqual([]); + expect(logs.some((entry) => entry.kind === "prompt")).toBe(false); + }); + + it("does not mark backend unhealthy when a per-session cwd is missing", async () => { + const { runtime } = await createMockRuntime(); + const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd"); + + await runtime.probeAvailability(); + expect(runtime.isHealthy()).toBe(true); + + await expect( + runtime.ensureSession({ + sessionKey: "agent:codex:acp:missing-cwd", + agent: "codex", + mode: "persistent", + cwd: missingCwd, + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: expect.stringContaining("working directory does not exist"), + }); + expect(runtime.isHealthy()).toBe(true); + }); + + it("marks runtime unhealthy when command is missing", async () => { + const runtime = new AcpxRuntime( + { + command: "/definitely/missing/acpx", + cwd: process.cwd(), + permissionMode: "approve-reads", + nonInteractivePermissions: "fail", + queueOwnerTtlSeconds: 0.1, + }, + { logger: NOOP_LOGGER }, + ); + + await runtime.probeAvailability(); + expect(runtime.isHealthy()).toBe(false); + }); + + it("marks runtime healthy when command is available", async () => { + const { runtime } = await createMockRuntime(); + await runtime.probeAvailability(); + expect(runtime.isHealthy()).toBe(true); + }); + + it("returns doctor report for missing command", async () => { + const runtime = new AcpxRuntime( + { + command: "/definitely/missing/acpx", + cwd: process.cwd(), + permissionMode: "approve-reads", + nonInteractivePermissions: "fail", + queueOwnerTtlSeconds: 0.1, + }, + { logger: NOOP_LOGGER }, + ); + + const report = await runtime.doctor(); + expect(report.ok).toBe(false); + expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE"); + expect(report.installCommand).toContain("acpx"); + }); +}); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts new file mode 100644 index 000000000000..a5273c7e0f28 --- /dev/null +++ b/extensions/acpx/src/runtime.ts @@ -0,0 +1,578 @@ +import { createInterface } from "node:readline"; +import type { + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntime, + AcpRuntimeEnsureInput, + AcpRuntimeErrorCode, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + PluginLogger, +} from "openclaw/plugin-sdk"; +import { AcpRuntimeError } from "openclaw/plugin-sdk"; +import { + ACPX_LOCAL_INSTALL_COMMAND, + ACPX_PINNED_VERSION, + type ResolvedAcpxPluginConfig, +} from "./config.js"; +import { checkPinnedAcpxVersion } from "./ensure.js"; +import { + parseJsonLines, + parsePromptEventLine, + toAcpxErrorEvent, +} from "./runtime-internals/events.js"; +import { + resolveSpawnFailure, + spawnAndCollect, + spawnWithResolvedCommand, + waitForExit, +} from "./runtime-internals/process.js"; +import { + asOptionalString, + asTrimmedString, + buildPermissionArgs, + deriveAgentFromSessionKey, + isRecord, + type AcpxHandleState, + type AcpxJsonObject, +} from "./runtime-internals/shared.js"; + +export const ACPX_BACKEND_ID = "acpx"; + +const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:"; +const DEFAULT_AGENT_FALLBACK = "codex"; +const ACPX_CAPABILITIES: AcpRuntimeCapabilities = { + controls: ["session/set_mode", "session/set_config_option", "session/status"], +}; + +export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string { + const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); + return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`; +} + +export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null { + const trimmed = runtimeSessionName.trim(); + if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) { + return null; + } + const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length); + if (!encoded) { + return null; + } + try { + const raw = Buffer.from(encoded, "base64url").toString("utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return null; + } + const name = asTrimmedString(parsed.name); + const agent = asTrimmedString(parsed.agent); + const cwd = asTrimmedString(parsed.cwd); + const mode = asTrimmedString(parsed.mode); + const acpxRecordId = asOptionalString(parsed.acpxRecordId); + const backendSessionId = asOptionalString(parsed.backendSessionId); + const agentSessionId = asOptionalString(parsed.agentSessionId); + if (!name || !agent || !cwd) { + return null; + } + if (mode !== "persistent" && mode !== "oneshot") { + return null; + } + return { + name, + agent, + cwd, + mode, + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(backendSessionId ? { backendSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + }; + } catch { + return null; + } +} + +export class AcpxRuntime implements AcpRuntime { + private healthy = false; + private readonly logger?: PluginLogger; + private readonly queueOwnerTtlSeconds: number; + + constructor( + private readonly config: ResolvedAcpxPluginConfig, + opts?: { + logger?: PluginLogger; + queueOwnerTtlSeconds?: number; + }, + ) { + this.logger = opts?.logger; + const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds; + this.queueOwnerTtlSeconds = + typeof requestedQueueOwnerTtlSeconds === "number" && + Number.isFinite(requestedQueueOwnerTtlSeconds) && + requestedQueueOwnerTtlSeconds >= 0 + ? requestedQueueOwnerTtlSeconds + : this.config.queueOwnerTtlSeconds; + } + + isHealthy(): boolean { + return this.healthy; + } + + async probeAvailability(): Promise { + const versionCheck = await checkPinnedAcpxVersion({ + command: this.config.command, + cwd: this.config.cwd, + expectedVersion: ACPX_PINNED_VERSION, + }); + if (!versionCheck.ok) { + this.healthy = false; + return; + } + + try { + const result = await spawnAndCollect({ + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + }); + this.healthy = result.error == null && (result.code ?? 0) === 0; + } catch { + this.healthy = false; + } + } + + async ensureSession(input: AcpRuntimeEnsureInput): Promise { + const sessionName = asTrimmedString(input.sessionKey); + if (!sessionName) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + const agent = asTrimmedString(input.agent); + if (!agent) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required."); + } + const cwd = asTrimmedString(input.cwd) || this.config.cwd; + const mode = input.mode; + + const events = await this.runControlCommand({ + args: this.buildControlArgs({ + cwd, + command: [agent, "sessions", "ensure", "--name", sessionName], + }), + cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + const ensuredEvent = events.find( + (event) => + asOptionalString(event.agentSessionId) || + asOptionalString(event.acpxSessionId) || + asOptionalString(event.acpxRecordId), + ); + const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined; + const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined; + const backendSessionId = ensuredEvent + ? asOptionalString(ensuredEvent.acpxSessionId) + : undefined; + + return { + sessionKey: input.sessionKey, + backend: ACPX_BACKEND_ID, + runtimeSessionName: encodeAcpxRuntimeHandleState({ + name: sessionName, + agent, + cwd, + mode, + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(backendSessionId ? { backendSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + }), + cwd, + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(backendSessionId ? { backendSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + }; + } + + async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable { + const state = this.resolveHandleState(input.handle); + const args = this.buildPromptArgs({ + agent: state.agent, + sessionName: state.name, + cwd: state.cwd, + }); + + const cancelOnAbort = async () => { + await this.cancel({ + handle: input.handle, + reason: "abort-signal", + }).catch((err) => { + this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`); + }); + }; + const onAbort = () => { + void cancelOnAbort(); + }; + + if (input.signal?.aborted) { + await cancelOnAbort(); + return; + } + if (input.signal) { + input.signal.addEventListener("abort", onAbort, { once: true }); + } + const child = spawnWithResolvedCommand({ + command: this.config.command, + args, + cwd: state.cwd, + }); + child.stdin.on("error", () => { + // Ignore EPIPE when the child exits before stdin flush completes. + }); + + child.stdin.end(input.text); + + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + let sawDone = false; + let sawError = false; + const lines = createInterface({ input: child.stdout }); + try { + for await (const line of lines) { + const parsed = parsePromptEventLine(line); + if (!parsed) { + continue; + } + if (parsed.type === "done") { + sawDone = true; + } + if (parsed.type === "error") { + sawError = true; + } + yield parsed; + } + + const exit = await waitForExit(child); + if (exit.error) { + const spawnFailure = resolveSpawnFailure(exit.error, state.cwd); + if (spawnFailure === "missing-command") { + this.healthy = false; + throw new AcpRuntimeError( + "ACP_BACKEND_UNAVAILABLE", + `acpx command not found: ${this.config.command}`, + { cause: exit.error }, + ); + } + if (spawnFailure === "missing-cwd") { + throw new AcpRuntimeError( + "ACP_TURN_FAILED", + `ACP runtime working directory does not exist: ${state.cwd}`, + { cause: exit.error }, + ); + } + throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error }); + } + + if ((exit.code ?? 0) !== 0 && !sawError) { + yield { + type: "error", + message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`, + }; + return; + } + + if (!sawDone && !sawError) { + yield { type: "done" }; + } + } finally { + lines.close(); + if (input.signal) { + input.signal.removeEventListener("abort", onAbort); + } + } + } + + getCapabilities(): AcpRuntimeCapabilities { + return ACPX_CAPABILITIES; + } + + async getStatus(input: { handle: AcpRuntimeHandle }): Promise { + const state = this.resolveHandleState(input.handle); + const events = await this.runControlCommand({ + args: this.buildControlArgs({ + cwd: state.cwd, + command: [state.agent, "status", "--session", state.name], + }), + cwd: state.cwd, + fallbackCode: "ACP_TURN_FAILED", + ignoreNoSession: true, + }); + const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0]; + if (!detail) { + return { + summary: "acpx status unavailable", + }; + } + const status = asTrimmedString(detail.status) || "unknown"; + const acpxRecordId = asOptionalString(detail.acpxRecordId); + const acpxSessionId = asOptionalString(detail.acpxSessionId); + const agentSessionId = asOptionalString(detail.agentSessionId); + const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null; + const summary = [ + `status=${status}`, + acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null, + acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null, + pid != null ? `pid=${pid}` : null, + ] + .filter(Boolean) + .join(" "); + return { + summary, + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + details: detail, + }; + } + + async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise { + const state = this.resolveHandleState(input.handle); + const mode = asTrimmedString(input.mode); + if (!mode) { + throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required."); + } + await this.runControlCommand({ + args: this.buildControlArgs({ + cwd: state.cwd, + command: [state.agent, "set-mode", mode, "--session", state.name], + }), + cwd: state.cwd, + fallbackCode: "ACP_TURN_FAILED", + }); + } + + async setConfigOption(input: { + handle: AcpRuntimeHandle; + key: string; + value: string; + }): Promise { + const state = this.resolveHandleState(input.handle); + const key = asTrimmedString(input.key); + const value = asTrimmedString(input.value); + if (!key || !value) { + throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required."); + } + await this.runControlCommand({ + args: this.buildControlArgs({ + cwd: state.cwd, + command: [state.agent, "set", key, value, "--session", state.name], + }), + cwd: state.cwd, + fallbackCode: "ACP_TURN_FAILED", + }); + } + + async doctor(): Promise { + const versionCheck = await checkPinnedAcpxVersion({ + command: this.config.command, + cwd: this.config.cwd, + expectedVersion: ACPX_PINNED_VERSION, + }); + if (!versionCheck.ok) { + this.healthy = false; + const details = [ + `expected=${versionCheck.expectedVersion}`, + versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null, + ].filter((detail): detail is string => Boolean(detail)); + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: versionCheck.message, + installCommand: versionCheck.installCommand, + details, + }; + } + + try { + const result = await spawnAndCollect({ + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + }); + if (result.error) { + const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd); + if (spawnFailure === "missing-command") { + this.healthy = false; + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: `acpx command not found: ${this.config.command}`, + installCommand: ACPX_LOCAL_INSTALL_COMMAND, + }; + } + if (spawnFailure === "missing-cwd") { + this.healthy = false; + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: `ACP runtime working directory does not exist: ${this.config.cwd}`, + }; + } + this.healthy = false; + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: result.error.message, + details: [String(result.error)], + }; + } + if ((result.code ?? 0) !== 0) { + this.healthy = false; + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, + }; + } + this.healthy = true; + return { + ok: true, + message: `acpx command available (${this.config.command}, version ${versionCheck.version})`, + }; + } catch (error) { + this.healthy = false; + return { + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }; + } + } + + async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise { + const state = this.resolveHandleState(input.handle); + await this.runControlCommand({ + args: this.buildControlArgs({ + cwd: state.cwd, + command: [state.agent, "cancel", "--session", state.name], + }), + cwd: state.cwd, + fallbackCode: "ACP_TURN_FAILED", + ignoreNoSession: true, + }); + } + + async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise { + const state = this.resolveHandleState(input.handle); + await this.runControlCommand({ + args: this.buildControlArgs({ + cwd: state.cwd, + command: [state.agent, "sessions", "close", state.name], + }), + cwd: state.cwd, + fallbackCode: "ACP_TURN_FAILED", + ignoreNoSession: true, + }); + } + + private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState { + const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName); + if (decoded) { + return decoded; + } + + const legacyName = asTrimmedString(handle.runtimeSessionName); + if (!legacyName) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + "Invalid acpx runtime handle: runtimeSessionName is missing.", + ); + } + + return { + name: legacyName, + agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK), + cwd: this.config.cwd, + mode: "persistent", + }; + } + + private buildControlArgs(params: { cwd: string; command: string[] }): string[] { + return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command]; + } + + private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] { + const args = [ + "--format", + "json", + "--json-strict", + "--cwd", + params.cwd, + ...buildPermissionArgs(this.config.permissionMode), + "--non-interactive-permissions", + this.config.nonInteractivePermissions, + ]; + if (this.config.timeoutSeconds) { + args.push("--timeout", String(this.config.timeoutSeconds)); + } + args.push("--ttl", String(this.queueOwnerTtlSeconds)); + args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-"); + return args; + } + + private async runControlCommand(params: { + args: string[]; + cwd: string; + fallbackCode: AcpRuntimeErrorCode; + ignoreNoSession?: boolean; + }): Promise { + const result = await spawnAndCollect({ + command: this.config.command, + args: params.args, + cwd: params.cwd, + }); + + if (result.error) { + const spawnFailure = resolveSpawnFailure(result.error, params.cwd); + if (spawnFailure === "missing-command") { + this.healthy = false; + throw new AcpRuntimeError( + "ACP_BACKEND_UNAVAILABLE", + `acpx command not found: ${this.config.command}`, + { cause: result.error }, + ); + } + if (spawnFailure === "missing-cwd") { + throw new AcpRuntimeError( + params.fallbackCode, + `ACP runtime working directory does not exist: ${params.cwd}`, + { cause: result.error }, + ); + } + throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error }); + } + + const events = parseJsonLines(result.stdout); + const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null; + if (errorEvent) { + if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") { + return events; + } + throw new AcpRuntimeError( + params.fallbackCode, + errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message, + ); + } + + if ((result.code ?? 0) !== 0) { + throw new AcpRuntimeError( + params.fallbackCode, + result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, + ); + } + return events; + } +} diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts new file mode 100644 index 000000000000..30fc9fa7205f --- /dev/null +++ b/extensions/acpx/src/service.test.ts @@ -0,0 +1,173 @@ +import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js"; +import { + __testing, + getAcpRuntimeBackend, + requireAcpRuntimeBackend, +} from "../../../src/acp/runtime/registry.js"; +import { ACPX_BUNDLED_BIN } from "./config.js"; +import { createAcpxRuntimeService } from "./service.js"; + +const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({ + ensurePinnedAcpxSpy: vi.fn(async () => {}), +})); + +vi.mock("./ensure.js", () => ({ + ensurePinnedAcpx: ensurePinnedAcpxSpy, +})); + +type RuntimeStub = AcpRuntime & { + probeAvailability(): Promise; + isHealthy(): boolean; +}; + +function createRuntimeStub(healthy: boolean): { + runtime: RuntimeStub; + probeAvailabilitySpy: ReturnType; + isHealthySpy: ReturnType; +} { + const probeAvailabilitySpy = vi.fn(async () => {}); + const isHealthySpy = vi.fn(() => healthy); + return { + runtime: { + ensureSession: vi.fn(async (input) => ({ + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: input.sessionKey, + })), + runTurn: vi.fn(async function* () { + yield { type: "done" as const }; + }), + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + async probeAvailability() { + await probeAvailabilitySpy(); + }, + isHealthy() { + return isHealthySpy(); + }, + }, + probeAvailabilitySpy, + isHealthySpy, + }; +} + +function createServiceContext( + overrides: Partial = {}, +): OpenClawPluginServiceContext { + return { + config: {}, + workspaceDir: "/tmp/workspace", + stateDir: "/tmp/state", + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + ...overrides, + }; +} + +describe("createAcpxRuntimeService", () => { + beforeEach(() => { + __testing.resetAcpRuntimeBackendsForTests(); + ensurePinnedAcpxSpy.mockReset(); + ensurePinnedAcpxSpy.mockImplementation(async () => {}); + }); + + it("registers and unregisters the acpx backend", async () => { + const { runtime, probeAvailabilitySpy } = createRuntimeStub(true); + const service = createAcpxRuntimeService({ + runtimeFactory: () => runtime, + }); + const context = createServiceContext(); + + await service.start(context); + expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); + + await vi.waitFor(() => { + expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce(); + expect(probeAvailabilitySpy).toHaveBeenCalledOnce(); + }); + + await service.stop?.(context); + expect(getAcpRuntimeBackend("acpx")).toBeNull(); + }); + + it("marks backend unavailable when runtime health check fails", async () => { + const { runtime } = createRuntimeStub(false); + const service = createAcpxRuntimeService({ + runtimeFactory: () => runtime, + }); + const context = createServiceContext(); + + await service.start(context); + + expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError); + try { + requireAcpRuntimeBackend("acpx"); + throw new Error("expected ACP backend lookup to fail"); + } catch (error) { + expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE"); + } + }); + + it("passes queue-owner TTL from plugin config", async () => { + const { runtime } = createRuntimeStub(true); + const runtimeFactory = vi.fn(() => runtime); + const service = createAcpxRuntimeService({ + runtimeFactory, + pluginConfig: { + queueOwnerTtlSeconds: 0.25, + }, + }); + const context = createServiceContext(); + + await service.start(context); + + expect(runtimeFactory).toHaveBeenCalledWith( + expect.objectContaining({ + queueOwnerTtlSeconds: 0.25, + pluginConfig: expect.objectContaining({ + command: ACPX_BUNDLED_BIN, + }), + }), + ); + }); + + it("uses a short default queue-owner TTL", async () => { + const { runtime } = createRuntimeStub(true); + const runtimeFactory = vi.fn(() => runtime); + const service = createAcpxRuntimeService({ + runtimeFactory, + }); + const context = createServiceContext(); + + await service.start(context); + + expect(runtimeFactory).toHaveBeenCalledWith( + expect.objectContaining({ + queueOwnerTtlSeconds: 0.1, + }), + ); + }); + + it("does not block startup while acpx ensure runs", async () => { + const { runtime } = createRuntimeStub(true); + ensurePinnedAcpxSpy.mockImplementation(() => new Promise(() => {})); + const service = createAcpxRuntimeService({ + runtimeFactory: () => runtime, + }); + const context = createServiceContext(); + + const startResult = await Promise.race([ + Promise.resolve(service.start(context)).then(() => "started"), + new Promise((resolve) => setTimeout(() => resolve("timed_out"), 100)), + ]); + + expect(startResult).toBe("started"); + expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); + }); +}); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts new file mode 100644 index 000000000000..65768d00ce8a --- /dev/null +++ b/extensions/acpx/src/service.ts @@ -0,0 +1,102 @@ +import type { + AcpRuntime, + OpenClawPluginService, + OpenClawPluginServiceContext, + PluginLogger, +} from "openclaw/plugin-sdk"; +import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk"; +import { + ACPX_PINNED_VERSION, + resolveAcpxPluginConfig, + type ResolvedAcpxPluginConfig, +} from "./config.js"; +import { ensurePinnedAcpx } from "./ensure.js"; +import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js"; + +type AcpxRuntimeLike = AcpRuntime & { + probeAvailability(): Promise; + isHealthy(): boolean; +}; + +type AcpxRuntimeFactoryParams = { + pluginConfig: ResolvedAcpxPluginConfig; + queueOwnerTtlSeconds: number; + logger?: PluginLogger; +}; + +type CreateAcpxRuntimeServiceParams = { + pluginConfig?: unknown; + runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike; +}; + +function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike { + return new AcpxRuntime(params.pluginConfig, { + logger: params.logger, + queueOwnerTtlSeconds: params.queueOwnerTtlSeconds, + }); +} + +export function createAcpxRuntimeService( + params: CreateAcpxRuntimeServiceParams = {}, +): OpenClawPluginService { + let runtime: AcpxRuntimeLike | null = null; + let lifecycleRevision = 0; + + return { + id: "acpx-runtime", + async start(ctx: OpenClawPluginServiceContext): Promise { + const pluginConfig = resolveAcpxPluginConfig({ + rawConfig: params.pluginConfig, + workspaceDir: ctx.workspaceDir, + }); + const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime; + runtime = runtimeFactory({ + pluginConfig, + queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds, + logger: ctx.logger, + }); + + registerAcpRuntimeBackend({ + id: ACPX_BACKEND_ID, + runtime, + healthy: () => runtime?.isHealthy() ?? false, + }); + ctx.logger.info( + `acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`, + ); + + lifecycleRevision += 1; + const currentRevision = lifecycleRevision; + void (async () => { + try { + await ensurePinnedAcpx({ + command: pluginConfig.command, + logger: ctx.logger, + expectedVersion: ACPX_PINNED_VERSION, + }); + if (currentRevision !== lifecycleRevision) { + return; + } + await runtime?.probeAvailability(); + if (runtime?.isHealthy()) { + ctx.logger.info("acpx runtime backend ready"); + } else { + ctx.logger.warn("acpx runtime backend probe failed after local install"); + } + } catch (err) { + if (currentRevision !== lifecycleRevision) { + return; + } + ctx.logger.warn( + `acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(); + }, + async stop(_ctx: OpenClawPluginServiceContext): Promise { + lifecycleRevision += 1; + unregisterAcpRuntimeBackend(ACPX_BACKEND_ID); + runtime = null; + }, + }; +} diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index da6b3ad9afb7..f6f193e29ffc 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fef..904d21d4d3f2 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index efb4859fac42..5db42331207f 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); vi.mock("./reactions.js", () => ({ sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), @@ -54,6 +47,22 @@ describe("bluebubblesMessageActions", () => { const handleAction = bluebubblesMessageActions.handleAction!; const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); + const blueBubblesConfig = (): OpenClawConfig => ({ + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }); + const runReactAction = async (params: Record) => { + return await callHandleAction({ + action: "react", + params, + cfg: blueBubblesConfig(), + accountId: null, + }); + }; beforeEach(() => { vi.clearAllMocks(); @@ -292,23 +301,10 @@ describe("bluebubblesMessageActions", () => { it("sends reaction successfully with chatGuid", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( @@ -327,24 +323,11 @@ describe("bluebubblesMessageActions", () => { it("sends reaction removal successfully", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 22c5d3e42e8c..e774ef6c85ef 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -2,13 +2,13 @@ import { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS, createActionGate, + extractToolSend, jsonResult, readNumberParam, readReactionParams, readStringParam, type ChannelMessageActionAdapter, type ChannelMessageActionName, - type ChannelToolSend, } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { const account = resolveBlueBubblesAccount({ cfg: cfg, diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 47f6e6d03cc9..da431c7325f9 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -4,7 +4,12 @@ import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { setBlueBubblesRuntime } from "./runtime.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatus, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesAttachment } from "./types.js"; const mockFetch = vi.fn(); @@ -59,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => { setBlueBubblesRuntime(runtimeStub); }); + async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { + const largeBuffer = new Uint8Array(params.bufferBytes); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), + }), + ).rejects.toThrow("too large"); + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -170,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => { }); it("throws when attachment exceeds max bytes", async () => { - const largeBuffer = new Uint8Array(10 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + await expectAttachmentTooLarge({ + bufferBytes: 10 * 1024 * 1024, + maxBytes: 5 * 1024 * 1024, }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - maxBytes: 5 * 1024 * 1024, - }), - ).rejects.toThrow("too large"); }); it("uses default max bytes when not specified", async () => { - const largeBuffer = new Uint8Array(9 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("too large"); + await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); }); it("uses attachment mimeType as fallback when response has no content-type", async () => { @@ -269,6 +268,67 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["localhost"] }); + }); + + it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-private-ip" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://192.168.1.5:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["192.168.1.5"] }); + }); }); describe("sendBlueBubblesAttachment", () => { @@ -278,7 +338,10 @@ describe("sendBlueBubblesAttachment", () => { fetchRemoteMediaMock.mockClear(); setBlueBubblesRuntime(runtimeStub); vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); + mockBlueBubblesPrivateApiStatus( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.unknown, + ); }); afterEach(() => { @@ -381,7 +444,10 @@ describe("sendBlueBubblesAttachment", () => { }); it("downgrades attachment reply threading when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + vi.mocked(getCachedBlueBubblesPrivateApiStatus), + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), @@ -402,4 +468,32 @@ describe("sendBlueBubblesAttachment", () => { expect(bodyText).not.toContain('name="selectedMessageGuid"'); expect(bodyText).not.toContain('name="partIndex"'); }); + + it("warns and downgrades attachment reply threading when private API status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ + ...runtimeStub, + log: runtimeLog, + } as unknown as PluginRuntime); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-unknown", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="partIndex"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 48331f21571c..ca7ce69a89c4 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -3,9 +3,12 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { postMultipartFormData } from "./multipart.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; import { resolveRequestUrl } from "./request-url.js"; -import { getBlueBubblesRuntime } from "./runtime.js"; +import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { resolveChatGuidForTarget } from "./send.js"; import { @@ -59,6 +62,15 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) { return resolveBlueBubblesServerAccount(params); } +function safeExtractHostname(url: string): string | undefined { + try { + const hostname = new URL(url).hostname.trim(); + return hostname || undefined; + } catch { + return undefined; + } +} + type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed"; function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined { @@ -79,18 +91,24 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, password, }); const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; + const trustedHostname = safeExtractHostname(baseUrl); try { const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({ url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork + ? { allowPrivateNetwork: true } + : trustedHostname + ? { allowedHostnames: [trustedHostname] } + : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), @@ -139,6 +157,7 @@ export async function sendBlueBubblesAttachment(params: { contentType = contentType?.trim() || undefined; const { baseUrl, password, accountId } = resolveAccount(opts); const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). const isAudioMessage = wantsVoice; @@ -207,7 +226,7 @@ export async function sendBlueBubblesAttachment(params: { addField("chatGuid", chatGuid); addField("name", filename); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - if (privateApiStatus !== false) { + if (privateApiEnabled) { addField("method", "private-api"); } @@ -217,9 +236,13 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo && privateApiStatus !== false) { + if (trimmedReplyTo && privateApiEnabled) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); + } else if (trimmedReplyTo && privateApiStatus === null) { + warnBlueBubbles( + "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.", + ); } // Add optional caption diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index f372ca4614e8..cc37829bc9dd 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; +import { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + markBlueBubblesChatRead, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + sendBlueBubblesTyping, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; @@ -12,6 +22,44 @@ installBlueBubblesFetchTestHooks({ }); describe("chat", () => { + function mockOkTextResponse() { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + } + + async function expectCalledUrlIncludesPassword(params: { + password: string; + invoke: () => Promise; + }) { + mockOkTextResponse(); + await params.invoke(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(`password=${params.password}`); + } + + async function expectCalledUrlUsesConfigCredentials(params: { + serverHost: string; + password: string; + invoke: (cfg: { + channels: { bluebubbles: { serverUrl: string; password: string } }; + }) => Promise; + }) { + mockOkTextResponse(); + await params.invoke({ + channels: { + bluebubbles: { + serverUrl: `http://${params.serverHost}`, + password: params.password, + }, + }, + }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(params.serverHost); + expect(calledUrl).toContain(`password=${params.password}`); + } + describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty or whitespace", async () => { for (const chatGuid of ["", " "]) { @@ -63,18 +111,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -109,25 +153,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + markBlueBubblesChatRead("chat-123", { + cfg, + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); }); @@ -278,6 +311,188 @@ describe("chat", () => { }); }); + describe("editBlueBubblesMessage", () => { + it("throws when required args are missing", async () => { + await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid"); + await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText"); + }); + + it("sends edit request with default payload values", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage(" message-guid ", " updated text ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/message-guid/edit"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + editedMessage: "updated text", + backwardsCompatibilityMessage: "Edited to: updated text", + partIndex: 0, + }); + }); + + it("supports custom part index and backwards compatibility message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 3, + backwardsCompatMessage: "custom-backwards-message", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + text: () => Promise.resolve("Unprocessable"), + }); + + await expect( + editBlueBubblesMessage("message-guid", "new text", { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("edit failed (422): Unprocessable"); + }); + }); + + describe("unsendBlueBubblesMessage", () => { + it("throws when messageGuid is missing", async () => { + await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid"); + }); + + it("sends unsend request with default part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage(" msg-123 ", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/msg-123/unsend"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(0); + }); + + it("uses custom part index", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await unsendBlueBubblesMessage("msg-123", { + serverUrl: "http://localhost:1234", + password: "test-password", + partIndex: 2, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(2); + }); + }); + + describe("group chat mutation actions", () => { + it("renames chat", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await renameBlueBubblesChat(" chat-guid ", "New Group Name", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid"), + expect.objectContaining({ method: "PUT" }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.displayName).toBe("New Group Name"); + }); + + it("adds and removes participant using matching endpoint", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await addBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + await removeBlueBubblesParticipant("chat-guid", "+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + + const addBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(addBody.address).toBe("+15551234567"); + expect(removeBody.address).toBe("+15551234567"); + }); + + it("leaves chat without JSON body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await leaveBlueBubblesChat("chat-guid", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/chat-guid/leave"), + expect.objectContaining({ method: "POST" }), + ); + expect(mockFetch.mock.calls[0][1].body).toBeUndefined(); + expect(mockFetch.mock.calls[0][1].headers).toBeUndefined(); + }); + }); + describe("setGroupIconBlueBubbles", () => { it("throws when chatGuid is empty", async () => { await expect( @@ -344,18 +559,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -390,25 +601,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg, + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 354e7076722d..f5f83b1b6aec 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } +function resolvePartIndex(partIndex: number | undefined): number { + return typeof partIndex === "number" ? partIndex : 0; +} + +async function sendPrivateApiJsonRequest(params: { + opts: BlueBubblesChatOpts; + feature: string; + action: string; + path: string; + method: "POST" | "PUT" | "DELETE"; + payload?: unknown; +}): Promise { + const { baseUrl, password, accountId } = resolveAccount(params.opts); + assertPrivateApiEnabled(accountId, params.feature); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: params.path, + password, + }); + + const request: RequestInit = { method: params.method }; + if (params.payload !== undefined) { + request.headers = { "Content-Type": "application/json" }; + request.body = JSON.stringify(params.payload); + } + + const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + export async function markBlueBubblesChatRead( chatGuid: string, opts: BlueBubblesChatOpts = {}, @@ -97,34 +132,18 @@ export async function editBlueBubblesMessage( throw new Error("BlueBubbles edit requires newText"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "edit"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "edit", + action: "edit", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, - password, - }); - - const payload = { - editedMessage: trimmedText, - backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + payload: { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: resolvePartIndex(opts.partIndex), }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); - } + }); } /** @@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage( throw new Error("BlueBubbles unsend requires messageGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "unsend"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "unsend", + action: "unsend", + method: "POST", path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, - password, + payload: { partIndex: resolvePartIndex(opts.partIndex) }, }); - - const payload = { - partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, - }; - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -181,28 +182,14 @@ export async function renameBlueBubblesChat( throw new Error("BlueBubbles rename requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "renameGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "renameGroup", + action: "rename", + method: "PUT", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, - password, + payload: { displayName }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant( throw new Error("BlueBubbles addParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "addParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "addParticipant", + action: "addParticipant", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); - } } /** @@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant( throw new Error("BlueBubbles removeParticipant requires address"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "removeParticipant"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "removeParticipant", + action: "removeParticipant", + method: "DELETE", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, - password, + payload: { address: trimmedAddress }, }); - - const res = await blueBubblesFetchWithTimeout( - url, - { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address: trimmedAddress }), - }, - opts.timeoutMs, - ); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`, - ); - } } /** @@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat( throw new Error("BlueBubbles leaveChat requires chatGuid"); } - const { baseUrl, password, accountId } = resolveAccount(opts); - assertPrivateApiEnabled(accountId, "leaveGroup"); - const url = buildBlueBubblesApiUrl({ - baseUrl, + await sendPrivateApiJsonRequest({ + opts, + feature: "leaveGroup", + action: "leaveChat", + method: "POST", path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, - password, }); - - const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); - } } /** diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe16..e4bef3fd73bb 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/history.ts b/extensions/bluebubbles/src/history.ts new file mode 100644 index 000000000000..672e2c48c809 --- /dev/null +++ b/extensions/bluebubbles/src/history.ts @@ -0,0 +1,177 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesHistoryEntry = { + sender: string; + body: string; + timestamp?: number; + messageId?: string; +}; + +export type BlueBubblesHistoryFetchResult = { + entries: BlueBubblesHistoryEntry[]; + /** + * True when at least one API path returned a recognized response shape. + * False means all attempts failed or returned unusable data. + */ + resolved: boolean; +}; + +export type BlueBubblesMessageData = { + guid?: string; + text?: string; + handle_id?: string; + is_from_me?: boolean; + date_created?: number; + date_delivered?: number; + associated_message_guid?: string; + sender?: { + address?: string; + display_name?: string; + }; +}; + +export type BlueBubblesChatOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; +}; + +function resolveAccount(params: BlueBubblesChatOpts) { + return resolveBlueBubblesServerAccount(params); +} + +const MAX_HISTORY_FETCH_LIMIT = 100; +const HISTORY_SCAN_MULTIPLIER = 8; +const MAX_HISTORY_SCAN_MESSAGES = 500; +const MAX_HISTORY_BODY_CHARS = 2_000; + +function clampHistoryLimit(limit: number): number { + if (!Number.isFinite(limit)) { + return 0; + } + const normalized = Math.floor(limit); + if (normalized <= 0) { + return 0; + } + return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT); +} + +function truncateHistoryBody(text: string): string { + if (text.length <= MAX_HISTORY_BODY_CHARS) { + return text; + } + return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`; +} + +/** + * Fetch message history from BlueBubbles API for a specific chat. + * This provides the initial backfill for both group chats and DMs. + */ +export async function fetchBlueBubblesHistory( + chatIdentifier: string, + limit: number, + opts: BlueBubblesChatOpts = {}, +): Promise { + const effectiveLimit = clampHistoryLimit(limit); + if (!chatIdentifier.trim() || effectiveLimit <= 0) { + return { entries: [], resolved: true }; + } + + let baseUrl: string; + let password: string; + try { + ({ baseUrl, password } = resolveAccount(opts)); + } catch { + return { entries: [], resolved: false }; + } + + // Try different common API patterns for fetching messages + const possiblePaths = [ + `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`, + `/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`, + `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`, + ]; + + for (const path of possiblePaths) { + try { + const url = buildBlueBubblesApiUrl({ baseUrl, path, password }); + const res = await blueBubblesFetchWithTimeout( + url, + { method: "GET" }, + opts.timeoutMs ?? 10000, + ); + + if (!res.ok) { + continue; // Try next path + } + + const data = await res.json().catch(() => null); + if (!data) { + continue; + } + + // Handle different response structures + let messages: unknown[] = []; + if (Array.isArray(data)) { + messages = data; + } else if (data.data && Array.isArray(data.data)) { + messages = data.data; + } else if (data.messages && Array.isArray(data.messages)) { + messages = data.messages; + } else { + continue; + } + + const historyEntries: BlueBubblesHistoryEntry[] = []; + + const maxScannedMessages = Math.min( + Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit), + MAX_HISTORY_SCAN_MESSAGES, + ); + for (let i = 0; i < messages.length && i < maxScannedMessages; i++) { + const item = messages[i]; + const msg = item as BlueBubblesMessageData; + + // Skip messages without text content + const text = msg.text?.trim(); + if (!text) { + continue; + } + + const sender = msg.is_from_me + ? "me" + : msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown"; + const timestamp = msg.date_created || msg.date_delivered; + + historyEntries.push({ + sender, + body: truncateHistoryBody(text), + timestamp, + messageId: msg.guid, + }); + } + + // Sort by timestamp (oldest first for context) + historyEntries.sort((a, b) => { + const aTime = a.timestamp || 0; + const bTime = b.timestamp || 0; + return aTime - bTime; + }); + + return { + entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit + resolved: true, + }; + } catch (error) { + // Continue to next path + continue; + } + } + + // If none of the API paths worked, return empty history + return { entries: [], resolved: false }; +} diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts new file mode 100644 index 000000000000..3986909c2593 --- /dev/null +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; + +describe("normalizeWebhookMessage", () => { + it("falls back to DM chatGuid handle when sender handle is missing", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-1", + text: "hello", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + expect(result?.chatGuid).toBe("iMessage;-;+15551234567"); + }); + + it("does not infer sender from group chatGuid when sender handle is missing", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: { + guid: "msg-1", + text: "hello group", + isGroup: true, + isFromMe: false, + handle: null, + chatGuid: "iMessage;+;chat123456", + }, + }); + + expect(result).toBeNull(); + }); + + it("accepts array-wrapped payload data", () => { + const result = normalizeWebhookMessage({ + type: "new-message", + data: [ + { + guid: "msg-1", + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + }, + ], + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + }); +}); + +describe("normalizeWebhookReaction", () => { + it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { + const result = normalizeWebhookReaction({ + type: "updated-message", + data: { + guid: "msg-2", + associatedMessageGuid: "p:0/msg-1", + associatedMessageType: 2000, + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.senderId).toBe("+15551234567"); + expect(result?.messageId).toBe("p:0/msg-1"); + expect(result?.action).toBe("added"); + }); +}); diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 56566f209811..e591f21dfb99 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -1,4 +1,4 @@ -import { normalizeBlueBubblesHandle } from "./targets.js"; +import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import type { BlueBubblesAttachment } from "./types.js"; function asRecord(value: unknown): Record | null { @@ -629,18 +629,42 @@ export function parseTapbackText(params: { } function extractMessagePayload(payload: Record): Record | null { + const parseRecord = (value: unknown): Record | null => { + const record = asRecord(value); + if (record) { + return record; + } + if (Array.isArray(value)) { + for (const entry of value) { + const parsedEntry = parseRecord(entry); + if (parsedEntry) { + return parsedEntry; + } + } + return null; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + return parseRecord(JSON.parse(trimmed)); + } catch { + return null; + } + }; + const dataRaw = payload.data ?? payload.payload ?? payload.event; - const data = - asRecord(dataRaw) ?? - (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null); + const data = parseRecord(dataRaw); const messageRaw = payload.message ?? data?.message ?? data; - const message = - asRecord(messageRaw) ?? - (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); - if (!message) { - return null; + const message = parseRecord(messageRaw); + if (message) { + return message; } - return message; + return null; } export function normalizeWebhookMessage( @@ -700,7 +724,10 @@ export function normalizeWebhookMessage( : timestampRaw * 1000 : undefined; - const normalizedSender = normalizeBlueBubblesHandle(senderId); + // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender. + const senderFallbackFromChatGuid = + !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; + const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); if (!normalizedSender) { return null; } @@ -774,7 +801,9 @@ export function normalizeWebhookReaction( : timestampRaw * 1000 : undefined; - const normalizedSender = normalizeBlueBubblesHandle(senderId); + const senderFallbackFromChatGuid = + !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null; + const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || ""); if (!normalizedSender) { return null; } diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 77457c4f5efd..486864fa4c38 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1,17 +1,23 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + DM_GROUP_ACCESS_REASON, + createScopedPairingAccess, createReplyPrefixOptions, + evictOldHistoryKeys, logAckFailure, logInboundDrop, logTypingFailure, + readStoreAllowFromForDmPolicy, + recordPendingHistoryEntryIfEnabled, resolveAckReaction, - resolveDmGroupAccessDecision, - resolveEffectiveAllowFromLists, + resolveDmGroupAccessWithLists, resolveControlCommandGate, stripMarkdown, + type HistoryEntry, } from "openclaw/plugin-sdk"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; +import { fetchBlueBubblesHistory } from "./history.js"; import { sendBlueBubblesMedia } from "./media-send.js"; import { buildMessagePlaceholder, @@ -35,7 +41,7 @@ import type { BlueBubblesRuntimeEnv, WebhookTarget, } from "./monitor-shared.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; @@ -239,12 +245,189 @@ function resolveBlueBubblesAckReaction(params: { } } +/** + * In-memory rolling history map keyed by account + chat identifier. + * Populated from incoming messages during the session. + * API backfill is attempted until one fetch resolves (or retries are exhausted). + */ +const chatHistories = new Map(); +type HistoryBackfillState = { + attempts: number; + firstAttemptAt: number; + nextAttemptAt: number; + resolved: boolean; +}; + +const historyBackfills = new Map(); +const HISTORY_BACKFILL_BASE_DELAY_MS = 5_000; +const HISTORY_BACKFILL_MAX_DELAY_MS = 2 * 60 * 1000; +const HISTORY_BACKFILL_MAX_ATTEMPTS = 6; +const HISTORY_BACKFILL_RETRY_WINDOW_MS = 30 * 60 * 1000; +const MAX_STORED_HISTORY_ENTRY_CHARS = 2_000; +const MAX_INBOUND_HISTORY_ENTRY_CHARS = 1_200; +const MAX_INBOUND_HISTORY_TOTAL_CHARS = 12_000; + +function buildAccountScopedHistoryKey(accountId: string, historyIdentifier: string): string { + return `${accountId}\u0000${historyIdentifier}`; +} + +function historyDedupKey(entry: HistoryEntry): string { + const messageId = entry.messageId?.trim(); + if (messageId) { + return `id:${messageId}`; + } + return `fallback:${entry.sender}\u0000${entry.body}\u0000${entry.timestamp ?? ""}`; +} + +function truncateHistoryBody(body: string, maxChars: number): string { + const trimmed = body.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.length <= maxChars) { + return trimmed; + } + return `${trimmed.slice(0, maxChars).trimEnd()}...`; +} + +function mergeHistoryEntries(params: { + apiEntries: HistoryEntry[]; + currentEntries: HistoryEntry[]; + limit: number; +}): HistoryEntry[] { + if (params.limit <= 0) { + return []; + } + + const merged: HistoryEntry[] = []; + const seen = new Set(); + const appendUnique = (entry: HistoryEntry) => { + const key = historyDedupKey(entry); + if (seen.has(key)) { + return; + } + seen.add(key); + merged.push(entry); + }; + + for (const entry of params.apiEntries) { + appendUnique(entry); + } + for (const entry of params.currentEntries) { + appendUnique(entry); + } + + if (merged.length <= params.limit) { + return merged; + } + return merged.slice(merged.length - params.limit); +} + +function pruneHistoryBackfillState(): void { + for (const key of historyBackfills.keys()) { + if (!chatHistories.has(key)) { + historyBackfills.delete(key); + } + } +} + +function markHistoryBackfillResolved(historyKey: string): void { + const state = historyBackfills.get(historyKey); + if (state) { + state.resolved = true; + historyBackfills.set(historyKey, state); + return; + } + historyBackfills.set(historyKey, { + attempts: 0, + firstAttemptAt: Date.now(), + nextAttemptAt: Number.POSITIVE_INFINITY, + resolved: true, + }); +} + +function planHistoryBackfillAttempt(historyKey: string, now: number): HistoryBackfillState | null { + const existing = historyBackfills.get(historyKey); + if (existing?.resolved) { + return null; + } + if (existing && now - existing.firstAttemptAt > HISTORY_BACKFILL_RETRY_WINDOW_MS) { + markHistoryBackfillResolved(historyKey); + return null; + } + if (existing && existing.attempts >= HISTORY_BACKFILL_MAX_ATTEMPTS) { + markHistoryBackfillResolved(historyKey); + return null; + } + if (existing && now < existing.nextAttemptAt) { + return null; + } + + const attempts = (existing?.attempts ?? 0) + 1; + const firstAttemptAt = existing?.firstAttemptAt ?? now; + const backoffDelay = Math.min( + HISTORY_BACKFILL_BASE_DELAY_MS * 2 ** (attempts - 1), + HISTORY_BACKFILL_MAX_DELAY_MS, + ); + const state: HistoryBackfillState = { + attempts, + firstAttemptAt, + nextAttemptAt: now + backoffDelay, + resolved: false, + }; + historyBackfills.set(historyKey, state); + return state; +} + +function buildInboundHistorySnapshot(params: { + entries: HistoryEntry[]; + limit: number; +}): Array<{ sender: string; body: string; timestamp?: number }> | undefined { + if (params.limit <= 0 || params.entries.length === 0) { + return undefined; + } + const recent = params.entries.slice(-params.limit); + const selected: Array<{ sender: string; body: string; timestamp?: number }> = []; + let remainingChars = MAX_INBOUND_HISTORY_TOTAL_CHARS; + + for (let i = recent.length - 1; i >= 0; i--) { + const entry = recent[i]; + const body = truncateHistoryBody(entry.body, MAX_INBOUND_HISTORY_ENTRY_CHARS); + if (!body) { + continue; + } + if (selected.length > 0 && body.length > remainingChars) { + break; + } + selected.push({ + sender: entry.sender, + body, + timestamp: entry.timestamp, + }); + remainingChars -= body.length; + if (remainingChars <= 0) { + break; + } + } + + if (selected.length === 0) { + return undefined; + } + selected.reverse(); + return selected; +} + export async function processMessage( message: NormalizedWebhookMessage, target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; - const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false; + const pairing = createScopedPairingAccess({ + core, + channel: "bluebubbles", + accountId: account.accountId, + }); + const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId); const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; @@ -325,27 +508,20 @@ export async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const groupPolicy = account.config.groupPolicy ?? "allowlist"; - const storeAllowFrom = await core.channel.pairing - .readAllowFromStore("bluebubbles") - .catch(() => []); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, + const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "bluebubbles", + accountId: account.accountId, dmPolicy, + readStore: pairing.readStoreForDmPolicy, }); - const groupAllowEntry = formatGroupAllowlistEntry({ - chatGuid: message.chatGuid, - chatId: message.chatId ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }); - const groupName = message.chatName?.trim() || undefined; - const accessDecision = resolveDmGroupAccessDecision({ + const accessDecision = resolveDmGroupAccessWithLists({ isGroup, dmPolicy, groupPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, + allowFrom: configuredAllowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, isSenderAllowed: (allowFrom) => isAllowedBlueBubblesSender({ allowFrom, @@ -355,10 +531,18 @@ export async function processMessage( chatIdentifier: message.chatIdentifier ?? undefined, }), }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const groupAllowEntry = formatGroupAllowlistEntry({ + chatGuid: message.chatGuid, + chatId: message.chatId ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + const groupName = message.chatName?.trim() || undefined; if (accessDecision.decision !== "allow") { if (isGroup) { - if (accessDecision.reason === "groupPolicy=disabled") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)"); logGroupAllowlistHint({ runtime, @@ -369,7 +553,7 @@ export async function processMessage( }); return; } - if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)"); logGroupAllowlistHint({ runtime, @@ -380,7 +564,7 @@ export async function processMessage( }); return; } - if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { logVerbose( core, runtime, @@ -403,15 +587,14 @@ export async function processMessage( return; } - if (accessDecision.reason === "dmPolicy=disabled") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`); logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`); return; } if (accessDecision.decision === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "bluebubbles", + const { code, created } = await pairing.upsertPairingRequest({ id: message.senderId, meta: { name: message.senderName }, }); @@ -490,10 +673,11 @@ export async function processMessage( // Command gating (parity with iMessage/WhatsApp) const useAccessGroups = config.commands?.useAccessGroups !== false; const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); + const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom; const ownerAllowedForCommands = - effectiveAllowFrom.length > 0 + commandDmAllowFrom.length > 0 ? isAllowedBlueBubblesSender({ - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, sender: message.senderId, chatId: message.chatId ?? undefined, chatGuid: message.chatGuid ?? undefined, @@ -510,17 +694,16 @@ export async function processMessage( chatIdentifier: message.chatIdentifier ?? undefined, }) : false; - const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, hasControlCommand: hasControlCmd, }); - const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; + const commandAuthorized = commandGate.commandAuthorized; // Block control commands from unauthorized senders in groups if (isGroup && commandGate.shouldBlock) { @@ -808,9 +991,118 @@ export async function processMessage( .trim(); }; + // History: in-memory rolling map with bounded API backfill retries + const historyLimit = isGroup + ? (account.config.historyLimit ?? 0) + : (account.config.dmHistoryLimit ?? 0); + + const historyIdentifier = + chatGuid || + chatIdentifier || + (chatId ? String(chatId) : null) || + (isGroup ? null : message.senderId) || + ""; + const historyKey = historyIdentifier + ? buildAccountScopedHistoryKey(account.accountId, historyIdentifier) + : ""; + + // Record the current message into rolling history + if (historyKey && historyLimit > 0) { + const nowMs = Date.now(); + const senderLabel = message.fromMe ? "me" : message.senderName || message.senderId; + const normalizedHistoryBody = truncateHistoryBody(text, MAX_STORED_HISTORY_ENTRY_CHARS); + const currentEntries = recordPendingHistoryEntryIfEnabled({ + historyMap: chatHistories, + limit: historyLimit, + historyKey, + entry: normalizedHistoryBody + ? { + sender: senderLabel, + body: normalizedHistoryBody, + timestamp: message.timestamp ?? nowMs, + messageId: message.messageId ?? undefined, + } + : null, + }); + pruneHistoryBackfillState(); + + const backfillAttempt = planHistoryBackfillAttempt(historyKey, nowMs); + if (backfillAttempt) { + try { + const backfillResult = await fetchBlueBubblesHistory(historyIdentifier, historyLimit, { + cfg: config, + accountId: account.accountId, + }); + if (backfillResult.resolved) { + markHistoryBackfillResolved(historyKey); + } + if (backfillResult.entries.length > 0) { + const apiEntries: HistoryEntry[] = []; + for (const entry of backfillResult.entries) { + const body = truncateHistoryBody(entry.body, MAX_STORED_HISTORY_ENTRY_CHARS); + if (!body) { + continue; + } + apiEntries.push({ + sender: entry.sender, + body, + timestamp: entry.timestamp, + messageId: entry.messageId, + }); + } + const merged = mergeHistoryEntries({ + apiEntries, + currentEntries: + currentEntries.length > 0 ? currentEntries : (chatHistories.get(historyKey) ?? []), + limit: historyLimit, + }); + if (chatHistories.has(historyKey)) { + chatHistories.delete(historyKey); + } + chatHistories.set(historyKey, merged); + evictOldHistoryKeys(chatHistories); + logVerbose( + core, + runtime, + `backfilled ${backfillResult.entries.length} history messages for ${isGroup ? "group" : "DM"}: ${historyIdentifier}`, + ); + } else if (!backfillResult.resolved) { + const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts; + const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0); + logVerbose( + core, + runtime, + `history backfill unresolved for ${historyIdentifier}; retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs}`, + ); + } + } catch (err) { + const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts; + const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0); + logVerbose( + core, + runtime, + `history backfill failed for ${historyIdentifier}: ${String(err)} (retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs})`, + ); + } + } + } + + // Build inbound history from the in-memory map + let inboundHistory: Array<{ sender: string; body: string; timestamp?: number }> | undefined; + if (historyKey && historyLimit > 0) { + const entries = chatHistories.get(historyKey); + if (entries && entries.length > 0) { + inboundHistory = buildInboundHistorySnapshot({ + entries, + limit: historyLimit, + }); + } + } + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: rawBody, + InboundHistory: inboundHistory, RawBody: rawBody, CommandBody: rawBody, BodyForCommands: rawBody, @@ -1095,27 +1387,30 @@ export async function processReaction( target: WebhookTarget, ): Promise { const { account, config, runtime, core } = target; + const pairing = createScopedPairingAccess({ + core, + channel: "bluebubbles", + accountId: account.accountId, + }); if (reaction.fromMe) { return; } const dmPolicy = account.config.dmPolicy ?? "pairing"; const groupPolicy = account.config.groupPolicy ?? "allowlist"; - const storeAllowFrom = await core.channel.pairing - .readAllowFromStore("bluebubbles") - .catch(() => []); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "bluebubbles", + accountId: account.accountId, dmPolicy, + readStore: pairing.readStoreForDmPolicy, }); - const accessDecision = resolveDmGroupAccessDecision({ + const accessDecision = resolveDmGroupAccessWithLists({ isGroup: reaction.isGroup, dmPolicy, groupPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, isSenderAllowed: (allowFrom) => isAllowedBlueBubblesSender({ allowFrom, diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts index 88e840394173..c768385e03a1 100644 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ b/extensions/bluebubbles/src/monitor-shared.ts @@ -1,8 +1,10 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import type { BlueBubblesAccountConfig } from "./types.js"; +export { normalizeWebhookPath }; + export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; @@ -30,18 +32,6 @@ export type WebhookTarget = { export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; -export function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { const raw = config?.webhookPath?.trim(); if (raw) { diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 69f416b8265f..43777f648ade 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import { fetchBlueBubblesHistory } from "./history.js"; import { handleBlueBubblesWebhookRequest, registerBlueBubblesWebhookTarget, @@ -38,6 +39,10 @@ vi.mock("./reactions.js", async () => { }; }); +vi.mock("./history.js", () => ({ + fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), +})); + // Mock runtime const mockEnqueueSystemEvent = vi.fn(); const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); @@ -86,6 +91,7 @@ const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : [])); const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockResolveChunkMode = vi.fn(() => "length"); +const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); function createMockRuntime(): PluginRuntime { return { @@ -156,6 +162,24 @@ function createMockRuntime(): PluginRuntime { vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"], dispatchReplyFromConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + withReplyDispatcher: vi.fn( + async ({ + dispatcher, + run, + onSettled, + }: Parameters[0]) => { + try { + return await run(); + } finally { + dispatcher.markComplete(); + try { + await dispatcher.waitForIdle(); + } finally { + await onSettled?.(); + } + } + }, + ) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], finalizeInboundContext: vi.fn( (ctx: Record) => ctx, ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], @@ -355,6 +379,7 @@ describe("BlueBubbles webhook monitor", () => { vi.clearAllMocks(); // Reset short ID state between tests for predictable behavior _resetBlueBubblesShortIdState(); + mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true }); mockReadAllowFromStore.mockResolvedValue([]); mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); mockResolveRequireMention.mockReturnValue(false); @@ -2280,6 +2305,51 @@ describe("BlueBubbles webhook monitor", () => { expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); + + it("does not auto-authorize DM control commands in open mode without allowlists", async () => { + mockHasControlCommand.mockReturnValue(true); + + const account = createMockAccount({ + dmPolicy: "open", + allowFrom: [], + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "/status", + handle: { address: "+15559999999" }, + isGroup: false, + isFromMe: false, + guid: "msg-dm-open-unauthorized", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const latestDispatch = + mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[ + mockDispatchReplyWithBufferedBlockDispatcher.mock.calls.length - 1 + ]?.[0]; + expect(latestDispatch?.ctx?.CommandAuthorized).toBe(false); + }); }); describe("typing/read receipt toggles", () => { @@ -2991,6 +3061,279 @@ describe("BlueBubbles webhook monitor", () => { }); }); + describe("history backfill", () => { + it("scopes in-memory history by account to avoid cross-account leakage", async () => { + mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => { + if (opts?.accountId === "acc-a") { + return { + resolved: true, + entries: [ + { sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 }, + ], + }; + } + if (opts?.accountId === "acc-b") { + return { + resolved: true, + entries: [ + { sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 }, + ], + }; + } + return { resolved: true, entries: [] }; + }); + + const accountA: ResolvedBlueBubblesAccount = { + ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), + accountId: "acc-a", + }; + const accountB: ResolvedBlueBubblesAccount = { + ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), + accountId: "acc-b", + }; + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const unregisterA = registerBlueBubblesWebhookTarget({ + account: accountA, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + const unregisterB = registerBlueBubblesWebhookTarget({ + account: accountB, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + unregister = () => { + unregisterA(); + unregisterB(); + }; + + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook?password=password-a", { + type: "new-message", + data: { + text: "message for account a", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "a-msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }), + createMockResponse(), + ); + await flushAsync(); + + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook?password=password-b", { + type: "new-message", + data: { + text: "message for account b", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "b-msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }), + createMockResponse(), + ); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2); + const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; + const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0]; + const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; + const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; + expect(firstHistory.map((entry) => entry.body)).toContain("a-history"); + expect(secondHistory.map((entry) => entry.body)).toContain("b-history"); + expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history"); + }); + + it("dedupes and caps merged history to dmHistoryLimit", async () => { + mockFetchBlueBubblesHistory.mockResolvedValueOnce({ + resolved: true, + entries: [ + { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, + { sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 }, + ], + }); + + const account = createMockAccount({ dmHistoryLimit: 2 }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "current text", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15550002002", + date: Date.now(), + }, + }); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + const callArgs = getFirstDispatchCall(); + const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; + expect(inboundHistory).toHaveLength(2); + expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]); + expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1); + }); + + it("uses exponential backoff for unresolved backfill and stops after resolve", async () => { + mockFetchBlueBubblesHistory + .mockResolvedValueOnce({ resolved: false, entries: [] }) + .mockResolvedValueOnce({ + resolved: true, + entries: [ + { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, + ], + }); + + const account = createMockAccount({ dmHistoryLimit: 4 }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const mkPayload = (guid: string, text: string, now: number) => ({ + type: "new-message", + data: { + text, + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid, + chatGuid: "iMessage;-;+15550003003", + date: now, + }, + }); + + let now = 1_700_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + try { + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)), + createMockResponse(), + ); + await flushAsync(); + expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); + + now += 1_000; + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)), + createMockResponse(), + ); + await flushAsync(); + expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); + + now += 6_000; + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)), + createMockResponse(), + ); + await flushAsync(); + expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); + + const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0]; + const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; + expect(thirdHistory.map((entry) => entry.body)).toContain("older context"); + expect(thirdHistory.map((entry) => entry.body)).toContain("third text"); + + now += 10_000; + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)), + createMockResponse(), + ); + await flushAsync(); + expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("caps inbound history payload size to reduce prompt-bomb risk", async () => { + const huge = "x".repeat(8_000); + mockFetchBlueBubblesHistory.mockResolvedValueOnce({ + resolved: true, + entries: Array.from({ length: 20 }, (_, idx) => ({ + sender: `Friend ${idx}`, + body: `${huge} ${idx}`, + messageId: `hist-${idx}`, + timestamp: idx + 1, + })), + }); + + const account = createMockAccount({ dmHistoryLimit: 20 }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "latest text", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-bomb-1", + chatGuid: "iMessage;-;+15550004004", + date: Date.now(), + }, + }), + createMockResponse(), + ); + await flushAsync(); + + const callArgs = getFirstDispatchCall(); + const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; + const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0); + expect(inboundHistory.length).toBeLessThan(20); + expect(totalChars).toBeLessThanOrEqual(12_000); + expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true); + }); + }); + describe("fromMe messages", () => { it("ignores messages from self (fromMe=true)", async () => { const account = createMockAccount(); diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index ca6b42ab5df0..78b2876b5e0e 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { let next = cfg; const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); + const validateServerUrlInput = (value: unknown): string | undefined => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }; + const promptServerUrl = async (initialValue?: string): Promise => { + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + initialValue, + validate: validateServerUrlInput, + }); + return String(entered).trim(); + }; // Prompt for server URL let serverUrl = resolvedAccount.config.serverUrl?.trim(); @@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { ].join("\n"), "BlueBubbles server URL", ); - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(); } else { const keepUrl = await prompter.confirm({ message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, initialValue: true, }); if (!keepUrl) { - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - initialValue: serverUrl, - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(serverUrl); } } diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index e60c47dc6439..5ee95a26821d 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea return info.private_api; } +export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean { + return status === true; +} + +export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean { + return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId)); +} + /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 643a926b8897..419ccc81e452 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { sendBlueBubblesReaction } from "./reactions.js"; -vi.mock("./accounts.js", () => ({ - resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { - const config = cfg?.channels?.bluebubbles ?? {}; - return { - accountId: accountId ?? "default", - enabled: config.enabled !== false, - configured: Boolean(config.serverUrl && config.password), - config, - }; - }), -})); +vi.mock("./accounts.js", async () => { + const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js"); + return createBlueBubblesAccountsMockModule(); +}); const mockFetch = vi.fn(); @@ -26,6 +19,27 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { + async function expectRemovedReaction(emoji: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji, + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + } + it("throws when chatGuid is empty", async () => { await expect( sendBlueBubblesReaction({ @@ -215,45 +229,11 @@ describe("reactions", () => { }); it("sends reaction removal with dash prefix", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("love"); }); it("strips leading dash from emoji when remove flag is set", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "-love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("-love"); }); it("uses custom partIndex when provided", async () => { diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 2f183c74e4dc..c9468234d3e6 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,14 +1,34 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; let runtime: PluginRuntime | null = null; +type LegacyRuntimeLogShape = { log?: (message: string) => void }; export function setBlueBubblesRuntime(next: PluginRuntime): void { runtime = next; } +export function clearBlueBubblesRuntime(): void { + runtime = null; +} + +export function tryGetBlueBubblesRuntime(): PluginRuntime | null { + return runtime; +} + export function getBlueBubblesRuntime(): PluginRuntime { if (!runtime) { throw new Error("BlueBubbles runtime not initialized"); } return runtime; } + +export function warnBlueBubbles(message: string): void { + const formatted = `[bluebubbles] ${message}`; + // Backward-compatible with tests/legacy injections that pass { log }. + const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log; + if (typeof log === "function") { + log(formatted); + return; + } + console.warn(formatted); +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c1bcafe29cb3..6b2e5fe051fa 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,15 +1,22 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; -import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; +import { + BLUE_BUBBLES_PRIVATE_API_STATUS, + installBlueBubblesFetchTestHooks, + mockBlueBubblesPrivateApiStatusOnce, +} from "./test-harness.js"; import type { BlueBubblesSendTarget } from "./types.js"; const mockFetch = vi.fn(); +const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); installBlueBubblesFetchTestHooks({ mockFetch, - privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus), + privateApiStatusMock, }); function mockResolvedHandleTarget( @@ -37,6 +44,23 @@ function mockSendResponse(body: unknown) { }); } +function mockNewChatSendResponse(guid: string) { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid }, + }), + ), + }); +} + describe("send", () => { describe("resolveChatGuidForTarget", () => { const resolveHandleTargetGuid = async (data: Array>) => { @@ -446,20 +470,7 @@ describe("send", () => { }); it("strips markdown when creating a new chat", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-stripped" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-stripped"); const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { serverUrl: "http://localhost:1234", @@ -476,20 +487,7 @@ describe("send", () => { }); it("creates a new chat when handle target is missing", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-guid" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-guid"); const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { serverUrl: "http://localhost:1234", @@ -527,6 +525,10 @@ describe("send", () => { }); it("uses private-api when reply metadata is present", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-124" } }); @@ -548,7 +550,10 @@ describe("send", () => { }); it("downgrades threaded reply to plain send when private API is disabled", async () => { - vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.disabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-plain" } }); @@ -568,6 +573,10 @@ describe("send", () => { }); it("normalizes effect names and uses private-api for effects", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-uuid-125" } }); @@ -586,6 +595,38 @@ describe("send", () => { expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); }); + it("warns and downgrades private-api features when status is unknown", async () => { + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-uuid-unknown" } }); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + effectId: "invisible ink", + }); + + expect(result.messageId).toBe("msg-uuid-unknown"); + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + expect(warnSpy).not.toHaveBeenCalled(); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + expect(body.partIndex).toBeUndefined(); + expect(body.effectId).toBeUndefined(); + } finally { + clearBlueBubblesRuntime(); + warnSpy.mockRestore(); + } + }); + it("sends message with chat_guid target directly", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index c5614062f516..4719fb416f86 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { stripMarkdown } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { + getCachedBlueBubblesPrivateApiStatus, + isBlueBubblesPrivateApiStatusEnabled, +} from "./probe.js"; +import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import { @@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined { return raw; } +type PrivateApiDecision = { + canUsePrivateApi: boolean; + throwEffectDisabledError: boolean; + warningMessage?: string; +}; + +function resolvePrivateApiDecision(params: { + privateApiStatus: boolean | null; + wantsReplyThread: boolean; + wantsEffect: boolean; +}): PrivateApiDecision { + const { privateApiStatus, wantsReplyThread, wantsEffect } = params; + const needsPrivateApi = wantsReplyThread || wantsEffect; + const canUsePrivateApi = + needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); + const throwEffectDisabledError = wantsEffect && privateApiStatus === false; + if (!needsPrivateApi || privateApiStatus !== null) { + return { canUsePrivateApi, throwEffectDisabledError }; + } + const requested = [ + wantsReplyThread ? "reply threading" : null, + wantsEffect ? "message effects" : null, + ] + .filter(Boolean) + .join(" + "); + return { + canUsePrivateApi, + throwEffectDisabledError, + warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`, + }; +} + type BlueBubblesChatRecord = Record; function extractChatGuid(chat: BlueBubblesChatRecord): string | null { @@ -372,30 +408,36 @@ export async function sendMessageBlueBubbles( const effectId = resolveEffectId(opts.effectId); const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); const wantsEffect = Boolean(effectId); - const needsPrivateApi = wantsReplyThread || wantsEffect; - const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false; - if (wantsEffect && privateApiStatus === false) { + const privateApiDecision = resolvePrivateApiDecision({ + privateApiStatus, + wantsReplyThread, + wantsEffect, + }); + if (privateApiDecision.throwEffectDisabledError) { throw new Error( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", ); } + if (privateApiDecision.warningMessage) { + warnBlueBubbles(privateApiDecision.warningMessage); + } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, }; - if (canUsePrivateApi) { + if (privateApiDecision.canUsePrivateApi) { payload.method = "private-api"; } // Add reply threading support - if (wantsReplyThread && canUsePrivateApi) { + if (wantsReplyThread && privateApiDecision.canUsePrivateApi) { payload.selectedMessageGuid = opts.replyToMessageGuid; payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; } // Add message effects support - if (effectId) { + if (effectId && privateApiDecision.canUsePrivateApi) { payload.effectId = effectId; } diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index be9d0fa6770e..b136de3095ce 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean { return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); } +function parseGroupTarget(params: { + trimmed: string; + lower: string; + requireValue: boolean; +}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { + if (!params.lower.startsWith("group:")) { + return null; + } + const value = stripPrefix(params.trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + if (params.requireValue) { + throw new Error("group target is required"); + } + return null; +} + +function parseRawChatIdentifierTarget( + trimmed: string, +): { kind: "chat_identifier"; chatIdentifier: string } | null { + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + return null; +} + export function normalizeBlueBubblesHandle(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (!value) { - throw new Error("group target is required"); - } - return { kind: "chat_guid", chatGuid: value }; + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true }); + if (groupTarget) { + return groupTarget; } const rawChatGuid = parseRawChatGuid(trimmed); @@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return { kind: "chat_guid", chatGuid: rawChatGuid }; } - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", to: trimmed, service: "auto" }; @@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget return chatTarget; } - if (lower.startsWith("group:")) { - const value = stripPrefix(trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } - } - - // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier - // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false }); + if (groupTarget) { + return groupTarget; } - // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; + const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); + if (rawChatIdentifierTarget) { + return rawChatIdentifierTarget; } return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 627b04197baf..5f7351b2e9fc 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -1,6 +1,31 @@ import type { Mock } from "vitest"; import { afterEach, beforeEach, vi } from "vitest"; +export const BLUE_BUBBLES_PRIVATE_API_STATUS = { + enabled: true, + disabled: false, + unknown: null, +} as const; + +type BlueBubblesPrivateApiStatusMock = { + mockReturnValue: (value: boolean | null) => unknown; + mockReturnValueOnce: (value: boolean | null) => unknown; +}; + +export function mockBlueBubblesPrivateApiStatus( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValue(value); +} + +export function mockBlueBubblesPrivateApiStatusOnce( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValueOnce(value); +} + export function resolveBlueBubblesAccountFromConfig(params: { cfg?: { channels?: { bluebubbles?: Record } }; accountId?: string; @@ -22,11 +47,15 @@ export function createBlueBubblesAccountsMockModule() { type BlueBubblesProbeMockModule = { getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; + isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; }; export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { return { - getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), + getCachedBlueBubblesPrivateApiStatus: vi + .fn() + .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), + isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true), }; } @@ -41,7 +70,7 @@ export function installBlueBubblesFetchTestHooks(params: { vi.stubGlobal("fetch", params.mockFetch); params.mockFetch.mockReset(); params.privateApiStatusMock.mockReset(); - params.privateApiStatusMock.mockReturnValue(null); + params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown); }); afterEach(() => { diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a0..72ccd9918570 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; }; diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 155e611f6a80..cc3bad01a8f6 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7890659fef18..f3a32e4542f4 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,7 +1,12 @@ -import { spawn } from "node:child_process"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; +import { + approveDevicePairing, + listDevicePairing, + resolveGatewayBindUrl, + runPluginCommandWithTimeout, + resolveTailnetHostWithRunner, +} from "openclaw/plugin-sdk"; import qrcode from "qrcode-terminal"; function renderQrAscii(data: string): Promise { @@ -37,77 +42,6 @@ type ResolveAuthResult = { error?: string; }; -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout( - argv: string[], - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const [command, ...args] = argv; - if (!command) { - resolve({ code: 1, stdout: "", stderr: "command is required" }); - return; - } - const proc = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${timeoutMs}ms`, - }); - }, timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -239,48 +173,12 @@ function pickTailnetIPv4(): string | null { } async function resolveTailnetHost(): Promise { - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000); - if (result.code !== 0) { - continue; - } - const raw = result.stdout.trim(); - if (!raw) { - continue; - } - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; - if (dns && dns.length > 0) { - return dns.replace(/\.$/, ""); - } - const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : []; - if (ips.length > 0) { - return ips[0] ?? null; - } - } catch { - continue; - } - } - return null; -} - -function parsePossiblyNoisyJsonObject(raw: string): Record { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; - } + return await resolveTailnetHostWithRunner((argv, opts) => + runPluginCommandWithTimeout({ + argv, + timeoutMs: opts.timeoutMs, + }), + ); } function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { @@ -365,29 +263,16 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise ({ }, })); -vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({ OTLPMetricExporter: class {}, })); -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({ OTLPTraceExporter: class { constructor(options?: unknown) { traceExporterCtor(options); @@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ }, })); -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({ OTLPLogExporter: class {}, })); @@ -110,6 +110,10 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; import { emitDiagnosticEvent } from "openclaw/plugin-sdk"; import { createDiagnosticsOtelService } from "./service.js"; +const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test"; +const OTEL_TEST_ENDPOINT = "http://otel-collector:4318"; +const OTEL_TEST_PROTOCOL = "http/protobuf"; + function createLogger() { return { info: vi.fn(), @@ -119,7 +123,15 @@ function createLogger() { }; } -function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { +type OtelContextFlags = { + traces?: boolean; + metrics?: boolean; + logs?: boolean; +}; +function createOtelContext( + endpoint: string, + { traces = false, metrics = false, logs = false }: OtelContextFlags = {}, +): OpenClawPluginServiceContext { return { config: { diagnostics: { @@ -127,17 +139,46 @@ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext otel: { enabled: true, endpoint, - protocol: "http/protobuf", - traces: true, - metrics: false, - logs: false, + protocol: OTEL_TEST_PROTOCOL, + traces, + metrics, + logs, }, }, }, logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", + stateDir: OTEL_TEST_STATE_DIR, }; } + +function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { + return createOtelContext(endpoint, { traces: true }); +} + +type RegisteredLogTransport = (logObj: Record) => void; +function setupRegisteredTransports() { + const registeredTransports: RegisteredLogTransport[] = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + return { registeredTransports, stopTransport }; +} + +async function emitAndCaptureLog(logObj: Record) { + const { registeredTransports } = setupRegisteredTransports(); + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true }); + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.(logObj); + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + await service.stop?.(ctx); + return emitCall; +} + describe("diagnostics-otel service", () => { beforeEach(() => { telemetryState.counters.clear(); @@ -154,31 +195,10 @@ describe("diagnostics-otel service", () => { }); test("records message-flow metrics and spans", async () => { - const registeredTransports: Array<(logObj: Record) => void> = []; - const stopTransport = vi.fn(); - registerLogTransportMock.mockImplementation((transport) => { - registeredTransports.push(transport); - return stopTransport; - }); + const { registeredTransports } = setupRegisteredTransports(); const service = createDiagnosticsOtelService(); - const ctx: OpenClawPluginServiceContext = { - config: { - diagnostics: { - enabled: true, - otel: { - enabled: true, - endpoint: "http://otel-collector:4318", - protocol: "http/protobuf", - traces: true, - metrics: true, - logs: true, - }, - }, - }, - logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", - }; + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true }); await service.start(ctx); emitDiagnosticEvent({ @@ -293,4 +313,55 @@ describe("diagnostics-otel service", () => { expect(options?.url).toBe("https://collector.example.com/v1/Traces"); await service.stop?.(ctx); }); + + test("redacts sensitive data from log messages before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: "Using API key sk-1234567890abcdef1234567890abcdef", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + + expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef"); + expect(emitCall?.body).toContain("sk-123"); + expect(emitCall?.body).toContain("…"); + }); + + test("redacts sensitive data from log attributes before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 1: "auth configured", + _meta: { logLevelName: "DEBUG", date: new Date() }, + }); + + const tokenAttr = emitCall?.attributes?.["openclaw.token"]; + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + if (typeof tokenAttr === "string") { + expect(tokenAttr).toContain("…"); + } + }); + + test("redacts sensitive reason in session.state metric attributes", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "session.state", + state: "waiting", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + }); + + const sessionCounter = telemetryState.counters.get("openclaw.session.state"); + expect(sessionCounter?.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + "openclaw.reason": expect.stringContaining("…"), + }), + ); + const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; + expect(typeof attrs?.["openclaw.reason"]).toBe("string"); + expect(String(attrs?.["openclaw.reason"])).not.toContain( + "ghp_abcdefghijklmnopqrstuvwxyz123456", + ); + await service.stop?.(ctx); + }); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 78975eb36e2b..be9a547963f1 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1,8 +1,8 @@ import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; import type { SeverityNumber } from "@opentelemetry/api-logs"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; @@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; -import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk"; const DEFAULT_SERVICE_NAME = "openclaw"; @@ -54,6 +54,14 @@ function formatError(err: unknown): string { } } +function redactOtelAttributes(attributes: Record) { + const redactedAttributes: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value; + } + return redactedAttributes; +} + export function createDiagnosticsOtelService(): OpenClawPluginService { let sdk: NodeSDK | null = null; let logProvider: LoggerProvider | null = null; @@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes["openclaw.code.location"] = meta.path.filePathWithLine; } + // OTLP can leave the host boundary, so redact string fields before export. otelLogger.emit({ - body: message, + body: redactSensitiveText(message), severityText: logLevelName, severityNumber, - attributes, + attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }); } catch (err) { @@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const redactedError = redactSensitiveText(evt.error); const spanAttrs: Record = { ...attrs, - "openclaw.error": evt.error, + "openclaw.error": redactedError, }; if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); @@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { const span = tracer.startSpan("openclaw.webhook.error", { attributes: spanAttrs, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -496,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { } }; + const addSessionIdentityAttrs = ( + spanAttrs: Record, + evt: { sessionKey?: string; sessionId?: string }, + ) => { + if (evt.sessionKey) { + spanAttrs["openclaw.sessionKey"] = evt.sessionKey; + } + if (evt.sessionId) { + spanAttrs["openclaw.sessionId"] = evt.sessionId; + } + }; + const recordMessageProcessed = ( evt: Extract, ) => { @@ -511,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); } @@ -524,11 +541,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { spanAttrs["openclaw.messageId"] = String(evt.messageId); } if (evt.reason) { - spanAttrs["openclaw.reason"] = evt.reason; + spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); - if (evt.outcome === "error") { - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + if (evt.outcome === "error" && evt.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } span.end(); }; @@ -557,7 +574,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ) => { const attrs: Record = { "openclaw.state": evt.state }; if (evt.reason) { - attrs["openclaw.reason"] = evt.reason; + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); } sessionStateCounter.add(1, attrs); }; @@ -574,12 +591,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; spanAttrs["openclaw.ageMs"] = evt.ageMs; const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); @@ -645,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { }); if (logsEnabled) { - ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)"); } }, async stop() { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 98ca5edb26e3..2f2be908ce2a 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/discord", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Discord channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts new file mode 100644 index 000000000000..b5981e77d93b --- /dev/null +++ b/extensions/discord/src/channel.test.ts @@ -0,0 +1,36 @@ +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { discordPlugin } from "./channel.js"; +import { setDiscordRuntime } from "./runtime.js"; + +describe("discordPlugin outbound", () => { + it("forwards mediaLocalRoots to sendMessageDiscord", async () => { + const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" })); + setDiscordRuntime({ + channel: { + discord: { + sendMessageDiscord, + }, + }, + } as unknown as PluginRuntime); + + const result = await discordPlugin.outbound!.sendMedia!({ + cfg: {} as OpenClawConfig, + to: "channel:123", + text: "hi", + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp/agent-root"], + accountId: "work", + }); + + expect(sendMessageDiscord).toHaveBeenCalledWith( + "channel:123", + "hi", + expect.objectContaining({ + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp/agent-root"], + }), + ); + expect(result).toMatchObject({ channel: "discord", messageId: "m1" }); + }); +}); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7556f14e1542..5ef3ab09caed 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, @@ -22,6 +23,8 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -130,8 +133,12 @@ export const discordPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; @@ -305,11 +312,21 @@ export const discordPlugin: ChannelPlugin = { }); return { channel: "discord", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => { + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + silent, + }) => { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, mediaUrl, + mediaLocalRoots, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, @@ -331,16 +348,8 @@ export const discordPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectDiscordStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 8e2514b3b773..f8a139cd56d6 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -64,6 +64,95 @@ function registerHandlersForTest( return handlers; } +function getRequiredHandler( + handlers: Map unknown>, + hookName: string, +): (event: unknown, ctx: unknown) => unknown { + const handler = handlers.get(hookName); + if (!handler) { + throw new Error(`expected ${hookName} hook handler`); + } + return handler; +} + +function createSpawnEvent(overrides?: { + childSessionKey?: string; + agentId?: string; + label?: string; + mode?: string; + requester?: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string; + }; + threadRequested?: boolean; +}): { + childSessionKey: string; + agentId: string; + label: string; + mode: string; + requester: { + channel: string; + accountId: string; + to: string; + threadId?: string; + }; + threadRequested: boolean; +} { + const base = { + childSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "banana", + mode: "session", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "456", + }, + threadRequested: true, + }; + return { + ...base, + ...overrides, + requester: { + ...base.requester, + ...(overrides?.requester ?? {}), + }, + }; +} + +function createSpawnEventWithoutThread() { + return createSpawnEvent({ + label: "", + requester: { threadId: undefined }, + }); +} + +async function runSubagentSpawning( + config?: Record, + event = createSpawnEventWithoutThread(), +) { + const handlers = registerHandlersForTest(config); + const handler = getRequiredHandler(handlers, "subagent_spawning"); + return await handler(event, {}); +} + +async function expectSubagentSpawningError(params?: { + config?: Record; + errorContains?: string; + event?: ReturnType; +}) { + const result = await runSubagentSpawning(params?.config, params?.event); + expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: "error" }); + if (params?.errorContains) { + const errorText = (result as { error?: string }).error ?? ""; + expect(errorText).toContain(params.errorContains); + } +} + describe("discord subagent hook handlers", () => { beforeEach(() => { hookMocks.resolveDiscordAccount.mockClear(); @@ -90,27 +179,9 @@ describe("discord subagent hook handlers", () => { it("binds thread routing on subagent_spawning", async () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_spawning"); - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "banana", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - threadId: "456", - }, - threadRequested: true, - }, - {}, - ); + const result = await handler(createSpawnEvent(), {}); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({ @@ -127,82 +198,42 @@ describe("discord subagent hook handlers", () => { }); it("returns error when thread-bound subagent spawn is disabled", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: { - spawnSubagentSessions: false, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: false, + }, }, }, }, + errorContains: "spawnSubagentSessions=true", }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("spawnSubagentSessions=true"); }); it("returns error when global thread bindings are disabled", async () => { - const handlers = registerHandlersForTest({ - session: { - threadBindings: { - enabled: false, - }, - }, - channels: { - discord: { + await expectSubagentSpawningError({ + config: { + session: { threadBindings: { - spawnSubagentSessions: true, + enabled: false, }, }, - }, - }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", + channels: { + discord: { + threadBindings: { + spawnSubagentSessions: true, + }, + }, }, - threadRequested: true, }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); - const errorText = (result as { error?: string }).error ?? ""; - expect(errorText).toContain("threadBindings.enabled=true"); + errorContains: "threadBindings.enabled=true", + }); }); it("allows account-level threadBindings.enabled to override global disable", async () => { - const handlers = registerHandlersForTest({ + const result = await runSubagentSpawning({ session: { threadBindings: { enabled: false, @@ -221,79 +252,34 @@ describe("discord subagent hook handlers", () => { }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ status: "ok", threadBindingReady: true }); }); it("defaults thread-bound subagent spawn to disabled when unset", async () => { - const handlers = registerHandlersForTest({ - channels: { - discord: { - threadBindings: {}, + await expectSubagentSpawningError({ + config: { + channels: { + discord: { + threadBindings: {}, + }, }, }, }); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); - - expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); - expect(result).toMatchObject({ status: "error" }); }); it("no-ops when thread binding is requested on non-discord channel", async () => { - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", + const result = await runSubagentSpawning( + undefined, + createSpawnEvent({ requester: { channel: "signal", + accountId: "", to: "+123", + threadId: undefined, }, - threadRequested: true, - }, - {}, + }), ); expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled(); @@ -302,26 +288,7 @@ describe("discord subagent hook handlers", () => { it("returns error when thread bind fails", async () => { hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null); - const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_spawning"); - if (!handler) { - throw new Error("expected subagent_spawning hook handler"); - } - - const result = await handler( - { - childSessionKey: "agent:main:subagent:child", - agentId: "main", - mode: "session", - requester: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - threadRequested: true, - }, - {}, - ); + const result = await runSubagentSpawning(); expect(result).toMatchObject({ status: "error" }); const errorText = (result as { error?: string }).error ?? ""; @@ -330,10 +297,7 @@ describe("discord subagent hook handlers", () => { it("unbinds thread routing on subagent_ended", () => { const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_ended"); - if (!handler) { - throw new Error("expected subagent_ended hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_ended"); handler( { @@ -361,10 +325,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "777" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { @@ -404,10 +365,7 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "888" }, ]); const handlers = registerHandlersForTest(); - const handler = handlers.get("subagent_delivery_target"); - if (!handler) { - throw new Error("expected subagent_delivery_target hook handler"); - } + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); const result = handler( { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1debb8f4ee01..b0e7b21ef78f 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "@sinclair/typebox": "0.34.48", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts index 3fe464097666..5e0575bba06c 100644 --- a/extensions/feishu/src/bitable.ts +++ b/extensions/feishu/src/bitable.ts @@ -1,7 +1,8 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { createFeishuClient } from "./client.js"; -import type { FeishuConfig } from "./types.js"; +import { listEnabledFeishuAccounts } from "./accounts.js"; +import { createFeishuToolClient } from "./tool-account.js"; // ============ Helpers ============ @@ -64,10 +65,7 @@ function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki } /** Get app_token from wiki node_token */ -async function getAppTokenFromWiki( - client: ReturnType, - nodeToken: string, -): Promise { +async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise { const res = await client.wiki.space.getNode({ params: { token: nodeToken }, }); @@ -87,7 +85,7 @@ async function getAppTokenFromWiki( } /** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */ -async function getBitableMeta(client: ReturnType, url: string) { +async function getBitableMeta(client: Lark.Client, url: string) { const parsed = parseBitableUrl(url); if (!parsed) { throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL"); @@ -134,11 +132,7 @@ async function getBitableMeta(client: ReturnType, url }; } -async function listFields( - client: ReturnType, - appToken: string, - tableId: string, -) { +async function listFields(client: Lark.Client, appToken: string, tableId: string) { const res = await client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId }, }); @@ -161,7 +155,7 @@ async function listFields( } async function listRecords( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, pageSize?: number, @@ -186,12 +180,7 @@ async function listRecords( }; } -async function getRecord( - client: ReturnType, - appToken: string, - tableId: string, - recordId: string, -) { +async function getRecord(client: Lark.Client, appToken: string, tableId: string, recordId: string) { const res = await client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, }); @@ -205,7 +194,7 @@ async function getRecord( } async function createRecord( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, fields: Record, @@ -235,7 +224,7 @@ const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTi /** Clean up default placeholder rows and fields in a newly created Bitable table */ async function cleanupNewBitable( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, tableName: string, @@ -334,7 +323,7 @@ async function cleanupNewBitable( } async function createApp( - client: ReturnType, + client: Lark.Client, name: string, folderToken?: string, logger?: CleanupLogger, @@ -389,7 +378,7 @@ async function createApp( } async function createField( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, fieldName: string, @@ -417,7 +406,7 @@ async function createField( } async function updateRecord( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, recordId: string, @@ -532,208 +521,193 @@ const UpdateRecordSchema = Type.Object({ // ============ Tool Registration ============ export function registerFeishuBitableTools(api: OpenClawPluginApi) { - const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { - api.logger.debug?.("feishu_bitable: Feishu credentials not configured, skipping bitable tools"); + if (!api.config) { + api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools"); return; } - const getClient = () => createFeishuClient(feishuCfg); - - // Tool 0: feishu_bitable_get_meta (helper to parse URLs) - api.registerTool( - { - name: "feishu_bitable_get_meta", - label: "Feishu Bitable Get Meta", - description: - "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", - parameters: GetMetaSchema, - async execute(_toolCallId, params) { - const { url } = params as { url: string }; - try { - const result = await getBitableMeta(getClient(), url); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools"); + return; + } + + type AccountAwareParams = { accountId?: string }; + + const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) => + createFeishuToolClient({ api, executeParams: params, defaultAccountId }); + + const registerBitableTool = (params: { + name: string; + label: string; + description: string; + parameters: unknown; + execute: (args: { params: TParams; defaultAccountId?: string }) => Promise; + }) => { + api.registerTool( + (ctx) => ({ + name: params.name, + label: params.label, + description: params.description, + parameters: params.parameters, + async execute(_toolCallId, rawParams) { + try { + return json( + await params.execute({ + params: rawParams as TParams, + defaultAccountId: ctx.agentAccountId, + }), + ); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }), + { name: params.name }, + ); + }; + + registerBitableTool<{ url: string; accountId?: string }>({ + name: "feishu_bitable_get_meta", + label: "Feishu Bitable Get Meta", + description: + "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", + parameters: GetMetaSchema, + async execute({ params, defaultAccountId }) { + return getBitableMeta(getClient(params, defaultAccountId), params.url); }, - { name: "feishu_bitable_get_meta" }, - ); - - // Tool 1: feishu_bitable_list_fields - api.registerTool( - { - name: "feishu_bitable_list_fields", - label: "Feishu Bitable List Fields", - description: "List all fields (columns) in a Bitable table with their types and properties", - parameters: ListFieldsSchema, - async execute(_toolCallId, params) { - const { app_token, table_id } = params as { app_token: string; table_id: string }; - try { - const result = await listFields(getClient(), app_token, table_id); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({ + name: "feishu_bitable_list_fields", + label: "Feishu Bitable List Fields", + description: "List all fields (columns) in a Bitable table with their types and properties", + parameters: ListFieldsSchema, + async execute({ params, defaultAccountId }) { + return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id); }, - { name: "feishu_bitable_list_fields" }, - ); - - // Tool 2: feishu_bitable_list_records - api.registerTool( - { - name: "feishu_bitable_list_records", - label: "Feishu Bitable List Records", - description: "List records (rows) from a Bitable table with pagination support", - parameters: ListRecordsSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, page_size, page_token } = params as { - app_token: string; - table_id: string; - page_size?: number; - page_token?: string; - }; - try { - const result = await listRecords(getClient(), app_token, table_id, page_size, page_token); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + page_size?: number; + page_token?: string; + accountId?: string; + }>({ + name: "feishu_bitable_list_records", + label: "Feishu Bitable List Records", + description: "List records (rows) from a Bitable table with pagination support", + parameters: ListRecordsSchema, + async execute({ params, defaultAccountId }) { + return listRecords( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.page_size, + params.page_token, + ); }, - { name: "feishu_bitable_list_records" }, - ); - - // Tool 3: feishu_bitable_get_record - api.registerTool( - { - name: "feishu_bitable_get_record", - label: "Feishu Bitable Get Record", - description: "Get a single record by ID from a Bitable table", - parameters: GetRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, record_id } = params as { - app_token: string; - table_id: string; - record_id: string; - }; - try { - const result = await getRecord(getClient(), app_token, table_id, record_id); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + record_id: string; + accountId?: string; + }>({ + name: "feishu_bitable_get_record", + label: "Feishu Bitable Get Record", + description: "Get a single record by ID from a Bitable table", + parameters: GetRecordSchema, + async execute({ params, defaultAccountId }) { + return getRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.record_id, + ); }, - { name: "feishu_bitable_get_record" }, - ); - - // Tool 4: feishu_bitable_create_record - api.registerTool( - { - name: "feishu_bitable_create_record", - label: "Feishu Bitable Create Record", - description: "Create a new record (row) in a Bitable table", - parameters: CreateRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, fields } = params as { - app_token: string; - table_id: string; - fields: Record; - }; - try { - const result = await createRecord(getClient(), app_token, table_id, fields); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + fields: Record; + accountId?: string; + }>({ + name: "feishu_bitable_create_record", + label: "Feishu Bitable Create Record", + description: "Create a new record (row) in a Bitable table", + parameters: CreateRecordSchema, + async execute({ params, defaultAccountId }) { + return createRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.fields, + ); }, - { name: "feishu_bitable_create_record" }, - ); - - // Tool 5: feishu_bitable_update_record - api.registerTool( - { - name: "feishu_bitable_update_record", - label: "Feishu Bitable Update Record", - description: "Update an existing record (row) in a Bitable table", - parameters: UpdateRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, record_id, fields } = params as { - app_token: string; - table_id: string; - record_id: string; - fields: Record; - }; - try { - const result = await updateRecord(getClient(), app_token, table_id, record_id, fields); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + record_id: string; + fields: Record; + accountId?: string; + }>({ + name: "feishu_bitable_update_record", + label: "Feishu Bitable Update Record", + description: "Update an existing record (row) in a Bitable table", + parameters: UpdateRecordSchema, + async execute({ params, defaultAccountId }) { + return updateRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.record_id, + params.fields, + ); }, - { name: "feishu_bitable_update_record" }, - ); - - // Tool 6: feishu_bitable_create_app - api.registerTool( - { - name: "feishu_bitable_create_app", - label: "Feishu Bitable Create App", - description: "Create a new Bitable (multidimensional table) application", - parameters: CreateAppSchema, - async execute(_toolCallId, params) { - const { name, folder_token } = params as { name: string; folder_token?: string }; - try { - const result = await createApp(getClient(), name, folder_token, { - debug: (msg) => api.logger.debug?.(msg), - warn: (msg) => api.logger.warn?.(msg), - }); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({ + name: "feishu_bitable_create_app", + label: "Feishu Bitable Create App", + description: "Create a new Bitable (multidimensional table) application", + parameters: CreateAppSchema, + async execute({ params, defaultAccountId }) { + return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, { + debug: (msg) => api.logger.debug?.(msg), + warn: (msg) => api.logger.warn?.(msg), + }); }, - { name: "feishu_bitable_create_app" }, - ); - - // Tool 7: feishu_bitable_create_field - api.registerTool( - { - name: "feishu_bitable_create_field", - label: "Feishu Bitable Create Field", - description: "Create a new field (column) in a Bitable table", - parameters: CreateFieldSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, field_name, field_type, property } = params as { - app_token: string; - table_id: string; - field_name: string; - field_type: number; - property?: Record; - }; - try { - const result = await createField( - getClient(), - app_token, - table_id, - field_name, - field_type, - property, - ); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + field_name: string; + field_type: number; + property?: Record; + accountId?: string; + }>({ + name: "feishu_bitable_create_field", + label: "Feishu Bitable Create Field", + description: "Create a new field (column) in a Bitable table", + parameters: CreateFieldSchema, + async execute({ params, defaultAccountId }) { + return createField( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.field_name, + params.field_type, + params.property, + ); }, - { name: "feishu_bitable_create_field" }, - ); + }); api.logger.info?.("feishu_bitable: Registered bitable tools"); } diff --git a/extensions/feishu/src/bot.checkBotMentioned.test.ts b/extensions/feishu/src/bot.checkBotMentioned.test.ts index a6233e053501..c88b32925e1d 100644 --- a/extensions/feishu/src/bot.checkBotMentioned.test.ts +++ b/extensions/feishu/src/bot.checkBotMentioned.test.ts @@ -22,6 +22,20 @@ function makeEvent( }; } +function makePostEvent(content: unknown) { + return { + sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, + message: { + message_id: "msg_1", + chat_id: "oc_chat1", + chat_type: "group", + message_type: "post", + content: JSON.stringify(content), + mentions: [], + }, + }; +} + describe("parseFeishuMessageEvent – mentionedBot", () => { const BOT_OPEN_ID = "ou_bot_123"; @@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent – mentionedBot", () => { it("returns mentionedBot=true for post message with at (no top-level mentions)", () => { const BOT_OPEN_ID = "ou_bot_123"; - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }], [{ tag: "text", text: "What does this document say" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID); expect(ctx.mentionedBot).toBe(true); }); it("returns mentionedBot=false for post message with no at", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [[{ tag: "text", text: "hello" }]], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); it("returns mentionedBot=false for post message with at for another user", () => { - const postContent = JSON.stringify({ + const event = makePostEvent({ content: [ [{ tag: "at", user_id: "ou_other", user_name: "other" }], [{ tag: "text", text: "hello" }], ], }); - const event = { - sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } }, - message: { - message_id: "msg_1", - chat_id: "oc_chat1", - chat_type: "group", - message_type: "post", - content: postContent, - mentions: [], - }, - }; const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123"); expect(ctx.mentionedBot).toBe(false); }); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index b9cd691cbb27..ca0792f2e825 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,20 +1,30 @@ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuMessageEvent } from "./bot.js"; -import { handleFeishuMessage } from "./bot.js"; +import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; -const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted( - () => ({ - mockCreateFeishuReplyDispatcher: vi.fn(() => ({ - dispatcher: vi.fn(), - replyOptions: {}, - markDispatchIdle: vi.fn(), - })), - mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), - mockGetMessageFeishu: vi.fn().mockResolvedValue(null), +const { + mockCreateFeishuReplyDispatcher, + mockSendMessageFeishu, + mockGetMessageFeishu, + mockDownloadMessageResourceFeishu, + mockCreateFeishuClient, +} = vi.hoisted(() => ({ + mockCreateFeishuReplyDispatcher: vi.fn(() => ({ + dispatcher: vi.fn(), + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), + mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), + mockGetMessageFeishu: vi.fn().mockResolvedValue(null), + mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({ + buffer: Buffer.from("video"), + contentType: "video/mp4", + fileName: "clip.mp4", }), -); + mockCreateFeishuClient: vi.fn(), +})); vi.mock("./reply-dispatcher.js", () => ({ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, @@ -25,19 +35,98 @@ vi.mock("./send.js", () => ({ getMessageFeishu: mockGetMessageFeishu, })); +vi.mock("./media.js", () => ({ + downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: mockCreateFeishuClient, +})); + +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + } as RuntimeEnv; +} + +async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { + await handleFeishuMessage({ + cfg: params.cfg, + event: params.event, + runtime: createRuntimeEnv(), + }); +} + +describe("buildFeishuAgentBody", () => { + it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => { + const body = buildFeishuAgentBody({ + ctx: { + content: "hello world", + senderName: "Sender Name", + senderOpenId: "ou-sender", + messageId: "msg-42", + mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }], + }, + quotedContent: "previous message", + permissionErrorForAgent: { + code: 99991672, + message: "permission denied", + grantUrl: "https://open.feishu.cn/app/cli_test", + }, + }); + + expect(body).toBe( + '[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]', + ); + }); +}); + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi .fn() .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }); + const mockWithReplyDispatcher = vi.fn( + async ({ + dispatcher, + run, + onSettled, + }: Parameters[0]) => { + try { + return await run(); + } finally { + dispatcher.markComplete(); + try { + await dispatcher.waitForIdle(); + } finally { + await onSettled?.(); + } + } + }, + ); const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); const mockShouldComputeCommandAuthorized = vi.fn(() => true); const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }); const mockBuildPairingReply = vi.fn(() => "Pairing response"); + const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/inbound-clip.mp4", + contentType: "video/mp4", + }); beforeEach(() => { vi.clearAllMocks(); + mockCreateFeishuClient.mockReturnValue({ + contact: { + user: { + get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }), + }, + }, + }); setFeishuRuntime({ system: { enqueueSystemEvent: vi.fn(), @@ -56,17 +145,24 @@ describe("handleFeishuMessage command authorization", () => { formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), finalizeInboundContext: mockFinalizeInboundContext, dispatchReplyFromConfig: mockDispatchReplyFromConfig, + withReplyDispatcher: mockWithReplyDispatcher, }, commands: { shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, }, + media: { + saveMediaBuffer: mockSaveMediaBuffer, + }, pairing: { readAllowFromStore: mockReadAllowFromStore, upsertPairingRequest: mockUpsertPairingRequest, buildPairingReply: mockBuildPairingReply, }, }, + media: { + detectMime: vi.fn(async () => "application/octet-stream"), + }, } as unknown as PluginRuntime); }); @@ -96,17 +192,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, @@ -151,19 +237,12 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); - expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu"); + expect(mockReadAllowFromStore).toHaveBeenCalledWith({ + channel: "feishu", + accountId: "default", + }); expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled(); expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1); expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); @@ -198,20 +277,11 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockUpsertPairingRequest).toHaveBeenCalledWith({ channel: "feishu", + accountId: "default", id: "ou-unapproved", meta: { name: undefined }, }); @@ -262,17 +332,7 @@ describe("handleFeishuMessage command authorization", () => { }, }; - await handleFeishuMessage({ - cfg, - event, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv, - }); + await dispatchMessage({ cfg, event }); expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ useAccessGroups: true, @@ -286,4 +346,198 @@ describe("handleFeishuMessage command authorization", () => { }), ); }); + + it("falls back to top-level allowFrom for group command authorization", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(true); + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); + + const cfg: ClawdbotConfig = { + commands: { useAccessGroups: true }, + channels: { + feishu: { + allowFrom: ["ou-admin"], + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-admin", + }, + }, + message: { + message_id: "msg-group-command-fallback", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "/status" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({ + useAccessGroups: true, + authorizers: [{ configured: true, allowed: true }], + }); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + ChatType: "group", + CommandAuthorized: true, + SenderId: "ou-admin", + }), + ); + }); + + it("uses video file_key (not thumbnail image_key) for inbound video download", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-sender", + }, + }, + message: { + message_id: "msg-video-inbound", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "video", + content: JSON.stringify({ + file_key: "file_video_payload", + image_key: "img_thumb_payload", + file_name: "clip.mp4", + }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-video-inbound", + fileKey: "file_video_payload", + type: "file", + }), + ); + expect(mockSaveMediaBuffer).toHaveBeenCalledWith( + expect.any(Buffer), + "video/mp4", + "inbound", + expect.any(Number), + "clip.mp4", + ); + }); + + it("includes message_id in BodyForAgent on its own line", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-msgid", + }, + }, + message: { + message_id: "msg-message-id-line", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello", + }), + ); + }); + + it("dispatches once and appends permission notice to the main agent body", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + mockCreateFeishuClient.mockReturnValue({ + contact: { + user: { + get: vi.fn().mockRejectedValue({ + response: { + data: { + code: 99991672, + msg: "permission denied https://open.feishu.cn/app/cli_test", + }, + }, + }), + }, + }, + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + appId: "cli_test", + appSecret: "sec_test", + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-perm", + }, + }, + message: { + message_id: "msg-perm-1", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello group" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: expect.stringContaining( + "Permission grant URL: https://open.feishu.cn/app/cli_test", + ), + }), + ); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + BodyForAgent: expect.stringContaining("ou-perm: hello group"), + }), + ); + }); }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index bee417c5741c..61c65973762c 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -2,14 +2,18 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { buildAgentMediaPayload, buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, + createScopedPairingAccess, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, + recordPendingHistoryEntryIfEnabled, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; -import { tryRecordMessage } from "./dedup.js"; +import { tryRecordMessagePersistent } from "./dedup.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { downloadMessageResourceFeishu } from "./media.js"; @@ -409,7 +413,7 @@ async function resolveFeishuMediaList(params: { // For message media, always use messageResource API // The image.get API is only for images uploaded via im/v1/images, not for message attachments - const fileKey = mediaKeys.imageKey || mediaKeys.fileKey; + const fileKey = mediaKeys.fileKey || mediaKeys.imageKey; if (!fileKey) { return []; } @@ -493,6 +497,40 @@ export function parseFeishuMessageEvent( return ctx; } +export function buildFeishuAgentBody(params: { + ctx: Pick< + FeishuMessageContext, + "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" + >; + quotedContent?: string; + permissionErrorForAgent?: PermissionError; +}): string { + const { ctx, quotedContent, permissionErrorForAgent } = params; + let messageBody = ctx.content; + if (quotedContent) { + messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; + } + + // DMs already have per-sender sessions, but this label still improves attribution. + const speaker = ctx.senderName ?? ctx.senderOpenId; + messageBody = `${speaker}: ${messageBody}`; + + if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { + const targetNames = ctx.mentionTargets.map((t) => t.name).join(", "); + messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`; + } + + // Keep message_id on its own line so shared message-id hint stripping can parse it reliably. + messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`; + + if (permissionErrorForAgent) { + const grantUrl = permissionErrorForAgent.grantUrl ?? ""; + messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; + } + + return messageBody; +} + export async function handleFeishuMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent; @@ -510,15 +548,16 @@ export async function handleFeishuMessage(params: { const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; - // Dedup check: skip if this message was already processed + // Dedup check: skip if this message was already processed (memory + disk). const messageId = event.message.message_id; - if (!tryRecordMessage(messageId)) { + if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) { log(`feishu: skipping duplicate message ${messageId}`); return; } let ctx = parseFeishuMessageEvent(event, botOpenId); const isGroup = ctx.chatType === "group"; + const senderUserId = event.sender.sender_id.user_id?.trim() || undefined; // Resolve sender display name (best-effort) so the agent can attribute messages correctly. const senderResult = await resolveFeishuSenderName({ @@ -563,7 +602,18 @@ export async function handleFeishuMessage(params: { const useAccessGroups = cfg.commands?.useAccessGroups !== false; if (isGroup) { - const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "feishu", + accountId: account.accountId, + log, + }); const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); @@ -587,6 +637,7 @@ export async function handleFeishuMessage(params: { groupPolicy: "allowlist", allowFrom: senderAllowFrom, senderId: ctx.senderOpenId, + senderIds: [senderUserId], senderName: ctx.senderName, }); if (!senderAllowed) { @@ -625,6 +676,11 @@ export async function handleFeishuMessage(params: { try { const core = getFeishuRuntime(); + const pairing = createScopedPairingAccess({ + core, + channel: "feishu", + accountId: account.accountId, + }); const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized( ctx.content, cfg, @@ -633,19 +689,19 @@ export async function handleFeishuMessage(params: { !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuthorized) - ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => []) + ? await pairing.readAllowFromStore().catch(() => []) : []; const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom]; const dmAllowed = resolveFeishuAllowlistMatch({ allowFrom: effectiveDmAllowFrom, senderId: ctx.senderOpenId, + senderIds: [senderUserId], senderName: ctx.senderName, }).allowed; if (!isGroup && dmPolicy !== "open" && !dmAllowed) { if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "feishu", + const { code, created } = await pairing.upsertPairingRequest({ id: ctx.senderOpenId, meta: { name: ctx.senderName }, }); @@ -676,10 +732,13 @@ export async function handleFeishuMessage(params: { return; } - const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom; + const commandAllowFrom = isGroup + ? (groupConfig?.allowFrom ?? configAllowFrom) + : effectiveDmAllowFrom; const senderAllowedForCommands = resolveFeishuAllowlistMatch({ allowFrom: commandAllowFrom, senderId: ctx.senderOpenId, + senderIds: [senderUserId], senderName: ctx.senderName, }).allowed; const commandAuthorized = shouldComputeCommandAuthorized @@ -700,10 +759,10 @@ export async function handleFeishuMessage(params: { // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; + let topicSessionMode: "enabled" | "disabled" = "disabled"; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - const topicSessionMode = - groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; @@ -719,6 +778,14 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, + // Add parentPeer for binding inheritance in topic mode + parentPeer: + isGroup && ctx.rootId && topicSessionMode === "enabled" + ? { + kind: "group", + id: ctx.chatId, + } + : null, }); // Dynamic agent creation for DM users @@ -795,85 +862,15 @@ export async function handleFeishuMessage(params: { } const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - - // Build message body with quoted content if available - let messageBody = ctx.content; - if (quotedContent) { - messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; - } - - // Include a readable speaker label so the model can attribute instructions. - // (DMs already have per-sender sessions, but the prefix is still useful for clarity.) - const speaker = ctx.senderName ?? ctx.senderOpenId; - messageBody = `${speaker}: ${messageBody}`; - - // If there are mention targets, inform the agent that replies will auto-mention them - if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { - const targetNames = ctx.mentionTargets.map((t) => t.name).join(", "); - messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`; - } - + const messageBody = buildFeishuAgentBody({ + ctx, + quotedContent, + permissionErrorForAgent, + }); const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId; - - // If there's a permission error, dispatch a separate notification first if (permissionErrorForAgent) { - const grantUrl = permissionErrorForAgent.grantUrl ?? ""; - const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; - - const permissionBody = core.channel.reply.formatAgentEnvelope({ - channel: "Feishu", - from: envelopeFrom, - timestamp: new Date(), - envelope: envelopeOptions, - body: permissionNotifyBody, - }); - - const permissionCtx = core.channel.reply.finalizeInboundContext({ - Body: permissionBody, - BodyForAgent: permissionNotifyBody, - RawBody: permissionNotifyBody, - CommandBody: permissionNotifyBody, - From: feishuFrom, - To: feishuTo, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroup ? "group" : "direct", - GroupSubject: isGroup ? ctx.chatId : undefined, - SenderName: "system", - SenderId: "system", - Provider: "feishu" as const, - Surface: "feishu" as const, - MessageSid: `${ctx.messageId}:permission-error`, - Timestamp: Date.now(), - WasMentioned: false, - CommandAuthorized: commandAuthorized, - OriginatingChannel: "feishu" as const, - OriginatingTo: feishuTo, - }); - - const { - dispatcher: permDispatcher, - replyOptions: permReplyOptions, - markDispatchIdle: markPermIdle, - } = createFeishuReplyDispatcher({ - cfg, - agentId: route.agentId, - runtime: runtime as RuntimeEnv, - chatId: ctx.chatId, - replyToMessageId: ctx.messageId, - accountId: account.accountId, - }); - - log(`feishu[${account.accountId}]: dispatching permission error notification to agent`); - - await core.channel.reply.dispatchReplyFromConfig({ - ctx: permissionCtx, - cfg, - dispatcher: permDispatcher, - replyOptions: permReplyOptions, - }); - - markPermIdle(); + // Keep the notice in a single dispatch to avoid duplicate replies (#27372). + log(`feishu[${account.accountId}]: appending permission error notice to message body`); } const body = core.channel.reply.formatAgentEnvelope({ @@ -916,7 +913,7 @@ export async function handleFeishuMessage(params: { const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, - BodyForAgent: ctx.content, + BodyForAgent: messageBody, InboundHistory: inboundHistory, RawBody: ctx.content, CommandBody: ctx.content, @@ -951,16 +948,20 @@ export async function handleFeishuMessage(params: { }); log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); - - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }), }); - markDispatchIdle(); - if (isGroup && historyKey && chatHistories) { clearHistoryEntriesIfEnabled({ historyMap: chatHistories, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 98a622cdf46c..f222924170f2 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -4,6 +4,8 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, @@ -224,10 +226,12 @@ export const feishuPlugin: ChannelPlugin = { collectWarnings: ({ cfg, accountId }) => { const account = resolveFeishuAccount({ cfg, accountId }); const feishuCfg = account.config; - const defaultGroupPolicy = ( - cfg.channels as Record | undefined - )?.defaults?.groupPolicy; - const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 942d0c8853ce..64a278c4afe9 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema } from "./config-schema.js"; describe("FeishuConfigSchema webhook validation", () => { + it("applies top-level defaults", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.domain).toBe("feishu"); + expect(result.connectionMode).toBe("websocket"); + expect(result.webhookPath).toBe("/feishu/events"); + expect(result.dmPolicy).toBe("pairing"); + expect(result.groupPolicy).toBe("allowlist"); + expect(result.requireMention).toBe(true); + }); + + it("does not force top-level policy defaults into account config", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: {}, + }, + }); + + expect(result.accounts?.main?.dmPolicy).toBeUndefined(); + expect(result.accounts?.main?.groupPolicy).toBeUndefined(); + expect(result.accounts?.main?.requireMention).toBeUndefined(); + }); + it("rejects top-level webhook mode without verificationToken", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b1e9fa248798..f5b08e13ee78 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -112,6 +112,31 @@ export const FeishuGroupSchema = z }) .strict(); +const FeishuSharedConfigShape = { + webhookHost: z.string().optional(), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, + streaming: StreamingModeSchema, + tools: FeishuToolsConfigSchema, +}; + /** * Per-account configuration. * All fields are optional - missing fields inherit from top-level config. @@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z domain: FeishuDomainSchema.optional(), connectionMode: FeishuConnectionModeSchema.optional(), webhookPath: z.string().optional(), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - requireMention: z.boolean().optional(), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, + ...FeishuSharedConfigShape, }) .strict(); @@ -163,29 +167,11 @@ export const FeishuConfigSchema = z domain: FeishuDomainSchema.optional().default("feishu"), connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), webhookPath: z.string().optional().default("/feishu/events"), - webhookHost: z.string().optional(), - webhookPort: z.number().int().positive().optional(), - capabilities: z.array(z.string()).optional(), - markdown: MarkdownConfigSchema, - configWrites: z.boolean().optional(), + ...FeishuSharedConfigShape, dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), requireMention: z.boolean().optional().default(true), - groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), topicSessionMode: TopicSessionModeSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema, - mediaMaxMb: z.number().positive().optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, - renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown - streaming: StreamingModeSchema, // Enable streaming card mode (default: true) - tools: FeishuToolsConfigSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, // Multi-account configuration diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index 25677f628d56..b0fa4ce1687f 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -1,33 +1,54 @@ -// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages. -const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes -const DEDUP_MAX_SIZE = 1_000; -const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes -const processedMessageIds = new Map(); // messageId -> timestamp -let lastCleanupTime = Date.now(); +import os from "node:os"; +import path from "node:path"; +import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk"; -export function tryRecordMessage(messageId: string): boolean { - const now = Date.now(); +// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects. +const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; +const MEMORY_MAX_SIZE = 1_000; +const FILE_MAX_ENTRIES = 10_000; - // Throttled cleanup: evict expired entries at most once per interval. - if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) { - for (const [id, ts] of processedMessageIds) { - if (now - ts > DEDUP_TTL_MS) { - processedMessageIds.delete(id); - } - } - lastCleanupTime = now; - } +const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE }); - if (processedMessageIds.has(messageId)) { - return false; +function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { + const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); + if (stateOverride) { + return stateOverride; } - - // Evict oldest entries if cache is full. - if (processedMessageIds.size >= DEDUP_MAX_SIZE) { - const first = processedMessageIds.keys().next().value!; - processedMessageIds.delete(first); + if (env.VITEST || env.NODE_ENV === "test") { + return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-")); } + return path.join(os.homedir(), ".openclaw"); +} + +function resolveNamespaceFilePath(namespace: string): string { + const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_"); + return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`); +} + +const persistentDedupe = createPersistentDedupe({ + ttlMs: DEDUP_TTL_MS, + memoryMaxSize: MEMORY_MAX_SIZE, + fileMaxEntries: FILE_MAX_ENTRIES, + resolveFilePath: resolveNamespaceFilePath, +}); + +/** + * Synchronous dedup — memory only. + * Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}. + */ +export function tryRecordMessage(messageId: string): boolean { + return !memoryDedupe.check(messageId); +} - processedMessageIds.set(messageId, now); - return true; +export async function tryRecordMessagePersistent( + messageId: string, + namespace = "global", + log?: (...args: unknown[]) => void, +): Promise { + return persistentDedupe.checkAndRecord(messageId, { + namespace, + onDiskError: (error) => { + log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`); + }, + }); } diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts new file mode 100644 index 000000000000..6471192b6fe6 --- /dev/null +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -0,0 +1,76 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { describe, expect, test, vi } from "vitest"; +import { registerFeishuDocTools } from "./docx.js"; +import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; + +const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({ + __appId: creds?.appId, +})); + +vi.mock("./client.js", () => { + return { + createFeishuClient: (creds: { appId?: string } | undefined) => createFeishuClientMock(creds), + }; +}); + +// Patch SDK import so tool execution can run without network concerns. +vi.mock("@larksuiteoapi/node-sdk", () => { + return { + default: {}, + }; +}); + +describe("feishu_doc account selection", () => { + test("uses agentAccountId context when params omit accountId", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + accounts: { + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, + }, + }, + }, + } as OpenClawPluginApi["config"]; + + const { api, resolveTool } = createToolFactoryHarness(cfg); + registerFeishuDocTools(api); + + const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" }); + const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" }); + + await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" }); + await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" }); + + expect(createFeishuClientMock).toHaveBeenCalledTimes(2); + expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a"); + expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b"); + }); + + test("explicit accountId param overrides agentAccountId context", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + accounts: { + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, + }, + }, + }, + } as OpenClawPluginApi["config"]; + + const { api, resolveTool } = createToolFactoryHarness(cfg); + registerFeishuDocTools(api); + + const docTool = resolveTool("feishu_doc", { agentAccountId: "b" }); + await docTool.execute("call-override", { + action: "list_blocks", + doc_token: "d", + accountId: "a", + }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a"); + }); +}); diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index 14f400fab081..bcf1774f0865 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -104,6 +104,7 @@ describe("feishu_doc image fetch hardening", () => { const feishuDocTool = registerTool.mock.calls .map((call) => call[0]) + .map((tool) => (typeof tool === "function" ? tool({}) : tool)) .find((tool) => tool.name === "feishu_doc"); expect(feishuDocTool).toBeDefined(); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 195cc8c81e70..33cfe924d1d2 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -3,10 +3,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { getFeishuRuntime } from "./runtime.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { + createFeishuToolClient, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccount, +} from "./tool-account.js"; // ============ Helpers ============ @@ -454,53 +457,80 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { return; } - // Use first account's config for tools configuration - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); - const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024; + // Register if enabled on any account; account routing is resolved per execution. + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); - // Helper to get client for the default account - const getClient = () => createFeishuClient(firstAccount); const registered: string[] = []; + type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string }; + + const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) => + createFeishuToolClient({ api, executeParams: params, defaultAccountId }); + + const getMediaMaxBytes = ( + params: { accountId?: string } | undefined, + defaultAccountId?: string, + ) => + (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config + ?.mediaMaxMb ?? 30) * + 1024 * + 1024; // Main document tool with action-based dispatch if (toolsCfg.doc) { api.registerTool( - { - name: "feishu_doc", - label: "Feishu Doc", - description: - "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block", - parameters: FeishuDocSchema, - async execute(_toolCallId, params) { - const p = params as FeishuDocParams; - try { - const client = getClient(); - switch (p.action) { - case "read": - return json(await readDoc(client, p.doc_token)); - case "write": - return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes)); - case "append": - return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes)); - case "create": - return json(await createDoc(client, p.title, p.folder_token)); - case "list_blocks": - return json(await listBlocks(client, p.doc_token)); - case "get_block": - return json(await getBlock(client, p.doc_token, p.block_id)); - case "update_block": - return json(await updateBlock(client, p.doc_token, p.block_id, p.content)); - case "delete_block": - return json(await deleteBlock(client, p.doc_token, p.block_id)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_doc", + label: "Feishu Doc", + description: + "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block", + parameters: FeishuDocSchema, + async execute(_toolCallId, params) { + const p = params as FeishuDocExecuteParams; + try { + const client = getClient(p, defaultAccountId); + switch (p.action) { + case "read": + return json(await readDoc(client, p.doc_token)); + case "write": + return json( + await writeDoc( + client, + p.doc_token, + p.content, + getMediaMaxBytes(p, defaultAccountId), + ), + ); + case "append": + return json( + await appendDoc( + client, + p.doc_token, + p.content, + getMediaMaxBytes(p, defaultAccountId), + ), + ); + case "create": + return json(await createDoc(client, p.title, p.folder_token)); + case "list_blocks": + return json(await listBlocks(client, p.doc_token)); + case "get_block": + return json(await getBlock(client, p.doc_token, p.block_id)); + case "update_block": + return json(await updateBlock(client, p.doc_token, p.block_id, p.content)); + case "delete_block": + return json(await deleteBlock(client, p.doc_token, p.block_id)); + default: { + const exhaustiveCheck: never = p; + return json({ error: `Unknown action: ${String(exhaustiveCheck)}` }); + } + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_doc" }, ); @@ -510,7 +540,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { // Keep feishu_app_scopes as independent tool if (toolsCfg.scopes) { api.registerTool( - { + (ctx) => ({ name: "feishu_app_scopes", label: "Feishu App Scopes", description: @@ -518,13 +548,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { parameters: Type.Object({}), async execute() { try { - const result = await listAppScopes(getClient()); + const result = await listAppScopes(getClient(undefined, ctx.agentAccountId)); return json(result); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }, - }, + }), { name: "feishu_app_scopes" }, ); registered.push("feishu_app_scopes"); diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index beefceba35de..d4bde43aff39 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,9 +1,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; // ============ Helpers ============ @@ -180,45 +179,51 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.drive) { api.logger.debug?.("feishu_drive: drive tool disabled in config"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string }; api.registerTool( - { - name: "feishu_drive", - label: "Feishu Drive", - description: - "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", - parameters: FeishuDriveSchema, - async execute(_toolCallId, params) { - const p = params as FeishuDriveParams; - try { - const client = getClient(); - switch (p.action) { - case "list": - return json(await listFolder(client, p.folder_token)); - case "info": - return json(await getFileInfo(client, p.file_token)); - case "create_folder": - return json(await createFolder(client, p.name, p.folder_token)); - case "move": - return json(await moveFile(client, p.file_token, p.type, p.folder_token)); - case "delete": - return json(await deleteFile(client, p.file_token, p.type)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_drive", + label: "Feishu Drive", + description: + "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", + parameters: FeishuDriveSchema, + async execute(_toolCallId, params) { + const p = params as FeishuDriveExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "list": + return json(await listFolder(client, p.folder_token)); + case "info": + return json(await getFileInfo(client, p.file_token)); + case "create_folder": + return json(await createFolder(client, p.name, p.folder_token)); + case "move": + return json(await moveFile(client, p.file_token, p.type, p.folder_token)); + case "delete": + return json(await deleteFile(client, p.file_token, p.type)); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_drive" }, ); diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index b9e97703a1b5..fc600481e85b 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); @@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({ import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js"; +function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { + expect(pathValue).not.toContain(key); + expect(pathValue).not.toContain(".."); + + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); + const resolved = path.resolve(pathValue); + const rel = path.relative(tmpRoot, resolved); + expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("image-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(imageKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, imageKey); }); it("uses isolated temp paths for message resource downloads", async () => { @@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(result.buffer).toEqual(Buffer.from("resource-data")); expect(capturedPath).toBeDefined(); - expect(capturedPath).not.toContain(fileKey); - expect(capturedPath).not.toContain(".."); - - const tmpRoot = path.resolve(os.tmpdir()); - const resolved = path.resolve(capturedPath as string); - const rel = path.relative(tmpRoot, resolved); - expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); + expectPathIsolatedToTmpRoot(capturedPath as string, fileKey); }); it("rejects invalid image keys before calling feishu api", async () => { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index bbe56bbb02a2..73c5ff2652c0 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; export type DownloadImageResult = { buffer: Buffer; @@ -268,18 +268,11 @@ export async function sendImageFeishu(params: { accountId?: string; }): Promise { const { cfg, to, imageKey, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ image_key: imageKey }); if (replyToMessageId) { @@ -320,18 +313,11 @@ export async function sendFileFeishu(params: { }): Promise { const { cfg, to, fileKey, replyToMessageId, accountId } = params; const msgType = params.msgType ?? "file"; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ file_key: fileKey }); if (replyToMessageId) { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index b304ee6ed40a..97637e75efee 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -78,6 +78,41 @@ function buildConfig(params: { } as ClawdbotConfig; } +async function withRunningWebhookMonitor( + params: { + accountId: string; + path: string; + verificationToken: string; + }, + run: (url: string) => Promise, +) { + const port = await getFreePort(); + const cfg = buildConfig({ + accountId: params.accountId, + path: params.path, + port, + verificationToken: params.verificationToken, + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitorFeishuProvider({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + const url = `http://127.0.0.1:${port}${params.path}`; + await waitUntilServerReady(url); + + try { + await run(url); + } finally { + abortController.abort(); + await monitorPromise; + } +} + afterEach(() => { stopFeishuMonitor(); }); @@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => { it("returns 415 for POST requests without json content type", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-content-type"; - const cfg = buildConfig({ - accountId: "content-type", - path, - port, - verificationToken: "verify_token", - }); - - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - - expect(response.status).toBe(415); - expect(await response.text()).toBe("Unsupported Media Type"); - - abortController.abort(); - await monitorPromise; + await withRunningWebhookMonitor( + { + accountId: "content-type", + path: "/hook-content-type", + verificationToken: "verify_token", + }, + async (url) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + + expect(response.status).toBe(415); + expect(await response.text()).toBe("Unsupported Media Type"); + }, + ); }); it("rate limits webhook burst traffic with 429", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const port = await getFreePort(); - const path = "/hook-rate-limit"; - const cfg = buildConfig({ - accountId: "rate-limit", - path, - port, - verificationToken: "verify_token", - }); - - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - await waitUntilServerReady(`http://127.0.0.1:${port}${path}`); - - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`http://127.0.0.1:${port}${path}`, { - method: "POST", - headers: { "content-type": "text/plain" }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - expect(await response.text()).toBe("Too Many Requests"); - break; - } - } - - expect(saw429).toBe(true); - - abortController.abort(); - await monitorPromise; + await withRunningWebhookMonitor( + { + accountId: "rate-limit", + path: "/hook-rate-limit", + verificationToken: "verify_token", + }, + async (url) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + expect(await response.text()).toBe("Too Many Requests"); + break; + } + } + + expect(saw429).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index a2cf02dd2411..bb847ebabbef 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise ); } +async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{ + appId: string; + appSecret: string; +}> { + const appId = String( + await prompter.text({ + message: "Enter Feishu App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const appSecret = String( + await prompter.text({ + message: "Enter Feishu App Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { appId, appSecret }; +} + function setFeishuGroupPolicy( cfg: ClawdbotConfig, groupPolicy: "open" | "allowlist" | "disabled", @@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else if (hasConfigCreds) { const keep = await prompter.confirm({ @@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } } else { - appId = String( - await prompter.text({ - message: "Enter Feishu App ID", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appSecret = String( - await prompter.text({ - message: "Enter Feishu App Secret", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptFeishuCredentials(prompter); + appId = entered.appId; + appSecret = entered.appSecret; } if (appId && appSecret) { diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index f11fb9882ecd..92c3bb8cdd93 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -1,9 +1,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; // ============ Helpers ============ @@ -129,42 +128,50 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.perm) { api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string }; api.registerTool( - { - name: "feishu_perm", - label: "Feishu Perm", - description: "Feishu permission management. Actions: list, add, remove", - parameters: FeishuPermSchema, - async execute(_toolCallId, params) { - const p = params as FeishuPermParams; - try { - const client = getClient(); - switch (p.action) { - case "list": - return json(await listMembers(client, p.token, p.type)); - case "add": - return json( - await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), - ); - case "remove": - return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_perm", + label: "Feishu Perm", + description: "Feishu permission management. Actions: list, add, remove", + parameters: FeishuPermSchema, + async execute(_toolCallId, params) { + const p = params as FeishuPermExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "list": + return json(await listMembers(client, p.token, p.type)); + case "add": + return json( + await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), + ); + case "remove": + return json( + await removeMember(client, p.token, p.type, p.member_type, p.member_id), + ); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_perm" }, ); diff --git a/extensions/feishu/src/policy.test.ts b/extensions/feishu/src/policy.test.ts new file mode 100644 index 000000000000..8e7d24ba67b2 --- /dev/null +++ b/extensions/feishu/src/policy.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { isFeishuGroupAllowed, resolveFeishuAllowlistMatch } from "./policy.js"; + +describe("feishu policy", () => { + describe("resolveFeishuAllowlistMatch", () => { + it("allows wildcard", () => { + expect( + resolveFeishuAllowlistMatch({ + allowFrom: ["*"], + senderId: "ou-attacker", + }), + ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" }); + }); + + it("matches normalized ID entries", () => { + expect( + resolveFeishuAllowlistMatch({ + allowFrom: ["feishu:user:OU_ALLOWED"], + senderId: "ou_allowed", + }), + ).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" }); + }); + + it("supports user_id as an additional immutable sender candidate", () => { + expect( + resolveFeishuAllowlistMatch({ + allowFrom: ["on_user_123"], + senderId: "ou_other", + senderIds: ["on_user_123"], + }), + ).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" }); + }); + + it("does not authorize based on display-name collision", () => { + const victimOpenId = "ou_4f4ec5aa111122223333444455556666"; + + expect( + resolveFeishuAllowlistMatch({ + allowFrom: [victimOpenId], + senderId: "ou_attacker_real_open_id", + senderIds: ["on_attacker_user_id"], + senderName: victimOpenId, + }), + ).toEqual({ allowed: false }); + }); + }); + + describe("isFeishuGroupAllowed", () => { + it("matches group IDs with chat: prefix", () => { + expect( + isFeishuGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["chat:oc_group_123"], + senderId: "oc_group_123", + }), + ).toBe(true); + }); + }); +}); diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index 89e12ba859e9..6ddac42d0e6e 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -3,17 +3,52 @@ import type { ChannelGroupContext, GroupToolPolicyConfig, } from "openclaw/plugin-sdk"; -import { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk"; +import { normalizeFeishuTarget } from "./targets.js"; import type { FeishuConfig, FeishuGroupConfig } from "./types.js"; -export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">; +export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">; + +function normalizeFeishuAllowEntry(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + const withoutProviderPrefix = trimmed.replace(/^feishu:/i, ""); + const normalized = normalizeFeishuTarget(withoutProviderPrefix) ?? withoutProviderPrefix; + return normalized.trim().toLowerCase(); +} export function resolveFeishuAllowlistMatch(params: { allowFrom: Array; senderId: string; + senderIds?: Array; senderName?: string | null; }): FeishuAllowlistMatch { - return resolveAllowlistMatchSimple(params); + const allowFrom = params.allowFrom + .map((entry) => normalizeFeishuAllowEntry(String(entry))) + .filter(Boolean); + if (allowFrom.length === 0) { + return { allowed: false }; + } + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + + // Feishu allowlists are ID-based; mutable display names must never grant access. + const senderCandidates = [params.senderId, ...(params.senderIds ?? [])] + .map((entry) => normalizeFeishuAllowEntry(String(entry ?? ""))) + .filter(Boolean); + + for (const senderId of senderCandidates) { + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + } + + return { allowed: false }; } export function resolveFeishuGroupConfig(params: { @@ -56,6 +91,7 @@ export function isFeishuGroupAllowed(params: { groupPolicy: "open" | "allowlist" | "disabled"; allowFrom: Array; senderId: string; + senderIds?: Array; senderName?: string | null; }): boolean { const { groupPolicy } = params; diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts new file mode 100644 index 000000000000..7d0d28663cc3 --- /dev/null +++ b/extensions/feishu/src/send-target.ts @@ -0,0 +1,25 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; + +export function resolveFeishuSendTarget(params: { + cfg: ClawdbotConfig; + to: string; + accountId?: string; +}) { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + const client = createFeishuClient(account); + const receiveId = normalizeFeishuTarget(params.to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${params.to}`); + } + return { + client, + receiveId, + receiveIdType: resolveReceiveIdType(receiveId), + }; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index c97601ccccbb..341ff3ed64d6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js"; import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; -import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; +import type { FeishuSendResult } from "./types.js"; export type FeishuMessageInfo = { messageId: string; @@ -128,18 +128,7 @@ export async function sendMessageFeishu( params: SendFeishuMessageParams, ): Promise { const { cfg, to, text, replyToMessageId, mentions, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu", @@ -188,18 +177,7 @@ export type SendFeishuCardParams = { export async function sendCardFeishu(params: SendFeishuCardParams): Promise { const { cfg, to, card, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const content = JSON.stringify(card); if (replyToMessageId) { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 93cf41661088..56f1fc365571 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -132,6 +132,26 @@ export class FeishuStreamingSession { this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); } + private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise { + if (!this.state) { + return; + } + const apiBase = resolveApiBase(this.creds.domain); + this.state.sequence += 1; + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { + method: "PUT", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: text, + sequence: this.state.sequence, + uuid: `s_${this.state.cardId}_${this.state.sequence}`, + }), + }).catch((error) => onError?.(error)); + } + async update(text: string): Promise { if (!this.state || this.closed) { return; @@ -150,20 +170,7 @@ export class FeishuStreamingSession { return; } this.state.currentText = text; - this.state.sequence += 1; - const apiBase = resolveApiBase(this.creds.domain); - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch((e) => this.log?.(`Update failed: ${String(e)}`)); + await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`)); }); await this.queue; } @@ -181,19 +188,7 @@ export class FeishuStreamingSession { // Only send final update if content differs from what's already displayed if (text && text !== this.state.currentText) { - this.state.sequence += 1; - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch(() => {}); + await this.updateCardContent(text); this.state.currentText = text; } diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts new file mode 100644 index 000000000000..4baa667112c0 --- /dev/null +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -0,0 +1,111 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { registerFeishuBitableTools } from "./bitable.js"; +import { registerFeishuDriveTools } from "./drive.js"; +import { registerFeishuPermTools } from "./perm.js"; +import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; +import { registerFeishuWikiTools } from "./wiki.js"; + +const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({ + __appId: account?.appId, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account), +})); + +function createConfig(params: { + toolsA?: { + wiki?: boolean; + drive?: boolean; + perm?: boolean; + }; + toolsB?: { + wiki?: boolean; + drive?: boolean; + perm?: boolean; + }; +}): OpenClawPluginApi["config"] { + return { + channels: { + feishu: { + enabled: true, + accounts: { + a: { + appId: "app-a", + appSecret: "sec-a", + tools: params.toolsA, + }, + b: { + appId: "app-b", + appSecret: "sec-b", + tools: params.toolsB, + }, + }, + }, + }, + } as OpenClawPluginApi["config"]; +} + +describe("feishu tool account routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("wiki tool registers when first account disables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { wiki: false }, + toolsB: { wiki: true }, + }), + ); + registerFeishuWikiTools(api); + + const tool = resolveTool("feishu_wiki", { agentAccountId: "b" }); + await tool.execute("call", { action: "search" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("drive tool registers when first account disables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { drive: false }, + toolsB: { drive: true }, + }), + ); + registerFeishuDriveTools(api); + + const tool = resolveTool("feishu_drive", { agentAccountId: "b" }); + await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("perm tool registers when only second account enables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { perm: false }, + toolsB: { perm: true }, + }), + ); + registerFeishuPermTools(api); + + const tool = resolveTool("feishu_perm", { agentAccountId: "b" }); + await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => { + const { api, resolveTool } = createToolFactoryHarness(createConfig({})); + registerFeishuBitableTools(api); + + const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" }); + await tool.execute("call-ctx", { url: "invalid-url" }); + await tool.execute("call-override", { url: "invalid-url", accountId: "a" }); + + expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b"); + expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a"); + }); +}); diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts new file mode 100644 index 000000000000..72b5db9b7775 --- /dev/null +++ b/extensions/feishu/src/tool-account.ts @@ -0,0 +1,58 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { resolveToolsConfig } from "./tools-config.js"; +import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js"; + +type AccountAwareParams = { accountId?: string }; + +function normalizeOptionalAccountId(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveFeishuToolAccount(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; +}): ResolvedFeishuAccount { + if (!params.api.config) { + throw new Error("Feishu config unavailable"); + } + return resolveFeishuAccount({ + cfg: params.api.config, + accountId: + normalizeOptionalAccountId(params.executeParams?.accountId) ?? + normalizeOptionalAccountId(params.defaultAccountId), + }); +} + +export function createFeishuToolClient(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; +}): Lark.Client { + return createFeishuClient(resolveFeishuToolAccount(params)); +} + +export function resolveAnyEnabledFeishuToolsConfig( + accounts: ResolvedFeishuAccount[], +): Required { + const merged: Required = { + doc: false, + wiki: false, + drive: false, + perm: false, + scopes: false, + }; + for (const account of accounts) { + const cfg = resolveToolsConfig(account.config.tools); + merged.doc = merged.doc || cfg.doc; + merged.wiki = merged.wiki || cfg.wiki; + merged.drive = merged.drive || cfg.drive; + merged.perm = merged.perm || cfg.perm; + merged.scopes = merged.scopes || cfg.scopes; + } + return merged; +} diff --git a/extensions/feishu/src/tool-factory-test-harness.ts b/extensions/feishu/src/tool-factory-test-harness.ts new file mode 100644 index 000000000000..a945e0639005 --- /dev/null +++ b/extensions/feishu/src/tool-factory-test-harness.ts @@ -0,0 +1,76 @@ +import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk"; + +type ToolContextLike = { + agentAccountId?: string; +}; + +type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined; + +export type ToolLike = { + name: string; + execute: (toolCallId: string, params: unknown) => Promise | unknown; +}; + +type RegisteredTool = { + tool: AnyAgentTool | ToolFactoryLike; + opts?: { name?: string }; +}; + +function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike { + const candidate = tool as Partial; + const name = candidate.name ?? fallbackName; + const execute = candidate.execute; + if (!name || typeof execute !== "function") { + throw new Error(`Resolved tool is missing required fields (name=${String(name)})`); + } + return { + name, + execute: (toolCallId, params) => execute(toolCallId, params), + }; +} + +export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) { + const registered: RegisteredTool[] = []; + + const api: Pick = { + config: cfg, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + registerTool: (tool, opts) => { + registered.push({ tool, opts }); + }, + }; + + const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => { + for (const entry of registered) { + if (entry.opts?.name === name && typeof entry.tool !== "function") { + return asToolLike(entry.tool, name); + } + + if (typeof entry.tool === "function") { + const builtTools = toToolList(entry.tool(ctx)); + const hit = builtTools.find((tool) => (tool as { name?: string }).name === name); + if (hit) { + return asToolLike(hit, name); + } + } else if ((entry.tool as { name?: string }).name === name) { + return asToolLike(entry.tool, name); + } + } + throw new Error(`Tool not registered: ${name}`); + }; + + return { + api: api as OpenClawPluginApi, + resolveTool, + }; +} diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index dc76bcc6d75f..0c4383b06476 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -1,8 +1,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js"; // ============ Helpers ============ @@ -168,62 +167,68 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.wiki) { api.logger.debug?.("feishu_wiki: wiki tool disabled in config"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuWikiExecuteParams = FeishuWikiParams & { accountId?: string }; api.registerTool( - { - name: "feishu_wiki", - label: "Feishu Wiki", - description: - "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", - parameters: FeishuWikiSchema, - async execute(_toolCallId, params) { - const p = params as FeishuWikiParams; - try { - const client = getClient(); - switch (p.action) { - case "spaces": - return json(await listSpaces(client)); - case "nodes": - return json(await listNodes(client, p.space_id, p.parent_node_token)); - case "get": - return json(await getNode(client, p.token)); - case "search": - return json({ - error: - "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", - }); - case "create": - return json( - await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), - ); - case "move": - return json( - await moveNode( - client, - p.space_id, - p.node_token, - p.target_space_id, - p.target_parent_token, - ), - ); - case "rename": - return json(await renameNode(client, p.space_id, p.node_token, p.title)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_wiki", + label: "Feishu Wiki", + description: + "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", + parameters: FeishuWikiSchema, + async execute(_toolCallId, params) { + const p = params as FeishuWikiExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "spaces": + return json(await listSpaces(client)); + case "nodes": + return json(await listNodes(client, p.space_id, p.parent_node_token)); + case "get": + return json(await getNode(client, p.token)); + case "search": + return json({ + error: + "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", + }); + case "create": + return json( + await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), + ); + case "move": + return json( + await moveNode( + client, + p.space_id, + p.node_token, + p.target_space_id, + p.target_parent_token, + ), + ); + case "rename": + return json(await renameNode(client, p.space_id, p.node_token, p.title)); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_wiki" }, ); diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md deleted file mode 100644 index 4e1dee975eaf..000000000000 --- a/extensions/google-antigravity-auth/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Google Antigravity Auth (OpenClaw plugin) - -OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-antigravity-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-antigravity --set-default -``` - -## Notes - -- Antigravity uses Google Cloud project quotas. -- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts deleted file mode 100644 index 055cb15e00b6..000000000000 --- a/extensions/google-antigravity-auth/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - isWSL2Sync, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync -const decode = (s: string) => Buffer.from(s, "base64").toString(); -const CLIENT_ID = decode( - "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", -); -const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); -const REDIRECT_URI = "http://localhost:51121/oauth-callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking"; - -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; - -const CODE_ASSIST_ENDPOINTS = [ - "https://cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", -]; - -const RESPONSE_PAGE = ` - - - - OpenClaw Antigravity OAuth - - -
-

Authentication complete

-

You can return to the terminal.

-
- -`; - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function buildAuthUrl(params: { challenge: string; state: string }): string { - const url = new URL(AUTH_URL); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("response_type", "code"); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPES.join(" ")); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - return url.toString(); -} - -function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter in URL" }; - } - return { code, state }; - } catch { - return { error: "Paste the full redirect URL (not just the code)." }; - } -} - -async function startCallbackServer(params: { timeoutMs: number }) { - const redirect = new URL(REDIRECT_URI); - const port = redirect.port ? Number(redirect.port) : 51121; - - let settled = false; - let resolveCallback: (url: URL) => void; - let rejectCallback: (err: Error) => void; - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = (url) => { - if (settled) { - return; - } - settled = true; - resolve(url); - }; - rejectCallback = (err) => { - if (settled) { - return; - } - settled = true; - reject(err); - }; - }); - - const timeout = setTimeout(() => { - rejectCallback(new Error("Timed out waiting for OAuth callback")); - }, params.timeoutMs); - timeout.unref?.(); - - const server = createServer((request, response) => { - if (!request.url) { - response.writeHead(400, { "Content-Type": "text/plain" }); - response.end("Missing URL"); - return; - } - - const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); - if (url.pathname !== redirect.pathname) { - response.writeHead(404, { "Content-Type": "text/plain" }); - response.end("Not found"); - return; - } - - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(RESPONSE_PAGE); - resolveCallback(url); - - setImmediate(() => { - server.close(); - }); - }); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - server.off("error", onError); - reject(err); - }; - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function exchangeCode(params: { - code: string; - verifier: string; -}): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: params.code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: params.verifier, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token exchange failed: ${text}`); - } - - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expiresIn = data.expires_in ?? 0; - - if (!access) { - throw new Error("Token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Token exchange returned no refresh_token"); - } - - const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; - return { access, refresh, expires }; -} - -async function fetchUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { email?: string }; - return data.email; - } catch { - return undefined; - } -} - -async function fetchProjectId(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }; - - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - cloudaicompanionProject?: string | { id?: string }; - }; - - if (typeof data.cloudaicompanionProject === "string") { - return data.cloudaicompanionProject; - } - if ( - data.cloudaicompanionProject && - typeof data.cloudaicompanionProject === "object" && - data.cloudaicompanionProject.id - ) { - return data.cloudaicompanionProject.id; - } - } catch { - // ignore - } - } - - return DEFAULT_PROJECT_ID; -} - -async function loginAntigravity(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}): Promise<{ - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}> { - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - const authUrl = buildAuthUrl({ challenge, state }); - - let callbackServer: Awaited> | null = null; - const needsManual = shouldUseManualOAuthFlow(params.isRemote); - if (!needsManual) { - try { - callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); - } catch { - callbackServer = null; - } - } - - if (!callbackServer) { - await params.note( - [ - "Open the URL in your local browser.", - "After signing in, copy the full redirect URL and paste it back here.", - "", - `Auth URL: ${authUrl}`, - `Redirect URI: ${REDIRECT_URI}`, - ].join("\n"), - "Google Antigravity OAuth", - ); - // Output raw URL below the box for easy copying (fixes #1772) - params.log(""); - params.log("Copy this URL:"); - params.log(authUrl); - params.log(""); - } - - if (!needsManual) { - params.progress.update("Opening Google sign-in…"); - try { - await params.openUrl(authUrl); - } catch { - // ignore - } - } - - let code = ""; - let returnedState = ""; - - if (callbackServer) { - params.progress.update("Waiting for OAuth callback…"); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code") ?? ""; - returnedState = callback.searchParams.get("state") ?? ""; - await callbackServer.close(); - } else { - params.progress.update("Waiting for redirect URL…"); - const input = await params.prompt("Paste the redirect URL: "); - const parsed = parseCallbackInput(input); - if ("error" in parsed) { - throw new Error(parsed.error); - } - code = parsed.code; - returnedState = parsed.state; - } - - if (!code) { - throw new Error("Missing OAuth code"); - } - if (returnedState !== state) { - throw new Error("OAuth state mismatch. Please try again."); - } - - params.progress.update("Exchanging code for tokens…"); - const tokens = await exchangeCode({ code, verifier }); - const email = await fetchUserEmail(tokens.access); - const projectId = await fetchProjectId(tokens.access); - - params.progress.stop("Antigravity OAuth complete"); - return { ...tokens, email, projectId }; -} - -const antigravityPlugin = { - id: "google-antigravity-auth", - name: "Google Antigravity Auth", - description: "OAuth flow for Google Antigravity (Cloud Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "google-antigravity", - label: "Google Antigravity", - docsPath: "/providers/models", - aliases: ["antigravity"], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); - try { - const result = await loginAntigravity({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (message) => String(await ctx.prompter.text({ message })), - note: ctx.prompter.note, - log: (message) => ctx.runtime.log(message), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "google-antigravity", - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: [ - "Antigravity uses Google Cloud project quotas.", - "Enable Gemini for Google Cloud on your project if requests fail.", - ], - }); - } catch (err) { - spin.stop("Antigravity OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json deleted file mode 100644 index 2ef207f04863..000000000000 --- a/extensions/google-antigravity-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-antigravity-auth", - "providers": ["google-antigravity"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json deleted file mode 100644 index e730f4dcbe45..000000000000 --- a/extensions/google-antigravity-auth/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.22", - "private": true, - "description": "OpenClaw Google Antigravity OAuth provider plugin", - "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md index 07dcd13c52a6..bbca53ba1ce3 100644 --- a/extensions/google-gemini-cli-auth/README.md +++ b/extensions/google-gemini-cli-auth/README.md @@ -2,6 +2,12 @@ OAuth provider plugin for **Gemini CLI** (Google Code Assist). +## Account safety caution + +- This plugin is an unofficial integration and is not endorsed by Google. +- Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients. +- Use caution, review the applicable Google terms, and avoid using a mission-critical account. + ## Enable Bundled plugins are disabled by default. Enable this one: diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 018eae78dd6f..46a12a0a5eee 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; vi.mock("openclaw/plugin-sdk", () => ({ isWSL2Sync: () => false, + fetchWithSsrFGuard: async (params: { + url: string; + init?: RequestInit; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + }) => { + const fetchImpl = params.fetchImpl ?? globalThis.fetch; + const response = await fetchImpl(params.url, params.init); + return { + response, + finalUrl: params.url, + release: async () => {}, + }; + }, })); // Mock fs module before importing the module under test @@ -96,6 +109,41 @@ describe("extractGeminiCliCredentials", () => { return layout; } + function installNpmShimLayout(params: { oauth2Exists?: boolean; oauth2Content?: string }) { + const binDir = join(rootDir, "fake", "npm-bin"); + const geminiPath = join(binDir, "gemini"); + const resolvedPath = geminiPath; + const oauth2Path = join( + binDir, + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); + process.env.PATH = binDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(geminiPath)) { + return true; + } + if (params.oauth2Exists && normalized === normalizePath(oauth2Path)) { + return true; + } + return false; + }); + mockRealpathSync.mockReturnValue(resolvedPath); + if (params.oauth2Content !== undefined) { + mockReadFileSync.mockReturnValue(params.oauth2Content); + } + } + beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; @@ -127,6 +175,19 @@ describe("extractGeminiCliCredentials", () => { }); }); + it("extracts credentials when PATH entry is an npm global shim", async () => { + installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + }); + it("returns null when oauth2.js cannot be found", async () => { installGeminiLayout({ oauth2Exists: false, readdir: [] }); @@ -160,3 +221,204 @@ describe("extractGeminiCliCredentials", () => { expect(mockReadFileSync.mock.calls.length).toBe(readCount); }); }); + +describe("loginGeminiCliOAuth", () => { + const TOKEN_URL = "https://oauth2.googleapis.com/token"; + const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; + const LOAD_PROD = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; + const LOAD_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; + const LOAD_AUTOPUSH = + "https://autopush-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"; + + const ENV_KEYS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT_ID", + ] as const; + + function getExpectedPlatform(): "WINDOWS" | "MACOS" | "LINUX" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "linux") { + return "LINUX"; + } + return "MACOS"; + } + + function getRequestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + } + + function getHeaderValue(headers: HeadersInit | undefined, name: string): string | undefined { + if (!headers) { + return undefined; + } + if (headers instanceof Headers) { + return headers.get(name) ?? undefined; + } + if (Array.isArray(headers)) { + return headers.find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + } + return (headers as Record)[name]; + } + + function responseJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + let envSnapshot: Partial>; + beforeEach(() => { + envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; + delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID; + delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET; + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GOOGLE_CLOUD_PROJECT_ID; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = envSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + vi.unstubAllGlobals(); + }); + + it("falls back across loadCodeAssist endpoints with aligned headers and metadata", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = getRequestUrl(input); + requests.push({ url, init }); + + if (url === TOKEN_URL) { + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + if (url === USERINFO_URL) { + return responseJson({ email: "lobster@openclaw.ai" }); + } + if (url === LOAD_PROD) { + return responseJson({ error: { message: "temporary failure" } }, 503); + } + if (url === LOAD_DAILY) { + return responseJson({ + currentTier: { id: "standard-tier" }, + cloudaicompanionProject: { id: "daily-project" }, + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + let authUrl = ""; + const { loginGeminiCliOAuth } = await import("./oauth.js"); + const result = await loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: (msg) => { + const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); + if (found?.[0]) { + authUrl = found[0]; + } + }, + note: async () => {}, + prompt: async () => { + const state = new URL(authUrl).searchParams.get("state"); + return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; + }, + progress: { update: () => {}, stop: () => {} }, + }); + + expect(result.projectId).toBe("daily-project"); + const loadRequests = requests.filter((request) => + request.url.includes("v1internal:loadCodeAssist"), + ); + expect(loadRequests.map((request) => request.url)).toEqual([LOAD_PROD, LOAD_DAILY]); + + const firstHeaders = loadRequests[0]?.init?.headers; + expect(getHeaderValue(firstHeaders, "X-Goog-Api-Client")).toBe( + `gl-node/${process.versions.node}`, + ); + + const clientMetadata = getHeaderValue(firstHeaders, "Client-Metadata"); + expect(clientMetadata).toBeDefined(); + expect(JSON.parse(clientMetadata as string)).toEqual({ + ideType: "ANTIGRAVITY", + platform: getExpectedPlatform(), + pluginType: "GEMINI", + }); + + const body = JSON.parse(String(loadRequests[0]?.init?.body)); + expect(body).toEqual({ + metadata: { + ideType: "ANTIGRAVITY", + platform: getExpectedPlatform(), + pluginType: "GEMINI", + }, + }); + }); + + it("falls back to GOOGLE_CLOUD_PROJECT when all loadCodeAssist endpoints fail", async () => { + process.env.GOOGLE_CLOUD_PROJECT = "env-project"; + + const requests: string[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = getRequestUrl(input); + requests.push(url); + + if (url === TOKEN_URL) { + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + if (url === USERINFO_URL) { + return responseJson({ email: "lobster@openclaw.ai" }); + } + if ([LOAD_PROD, LOAD_DAILY, LOAD_AUTOPUSH].includes(url)) { + return responseJson({ error: { message: "unavailable" } }, 503); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + let authUrl = ""; + const { loginGeminiCliOAuth } = await import("./oauth.js"); + const result = await loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: (msg) => { + const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/); + if (found?.[0]) { + authUrl = found[0]; + } + }, + note: async () => {}, + prompt: async () => { + const state = new URL(authUrl).searchParams.get("state"); + return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`; + }, + progress: { update: () => {}, stop: () => {} }, + }); + + expect(result.projectId).toBe("env-project"); + expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); + expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 7977ab52981e..7e2280b9c9f9 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; import { delimiter, dirname, join } from "node:path"; -import { isWSL2Sync } from "openclaw/plugin-sdk"; +import { fetchWithSsrFGuard, isWSL2Sync } from "openclaw/plugin-sdk"; const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -13,7 +13,15 @@ const REDIRECT_URI = "http://localhost:8085/oauth2callback"; const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; -const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const CODE_ASSIST_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; +const CODE_ASSIST_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +const CODE_ASSIST_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; +const LOAD_CODE_ASSIST_ENDPOINTS = [ + CODE_ASSIST_ENDPOINT_PROD, + CODE_ASSIST_ENDPOINT_DAILY, + CODE_ASSIST_ENDPOINT_AUTOPUSH, +]; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; const SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", @@ -71,41 +79,45 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: } const resolvedPath = realpathSync(geminiPath); - const geminiCliDir = dirname(dirname(resolvedPath)); - - const searchPaths = [ - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "src", - "code_assist", - "oauth2.js", - ), - join( - geminiCliDir, - "node_modules", - "@google", - "gemini-cli-core", - "dist", - "code_assist", - "oauth2.js", - ), - ]; + const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); let content: string | null = null; - for (const p of searchPaths) { - if (existsSync(p)) { - content = readFileSync(p, "utf8"); + for (const geminiCliDir of geminiCliDirs) { + const searchPaths = [ + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ), + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "code_assist", + "oauth2.js", + ), + ]; + + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, "utf8"); + break; + } + } + if (content) { break; } - } - if (!content) { const found = findFile(geminiCliDir, "oauth2.js", 10); if (found) { content = readFileSync(found, "utf8"); + break; } } if (!content) { @@ -124,6 +136,30 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return null; } +function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { + const binDir = dirname(geminiPath); + const candidates = [ + dirname(dirname(resolvedPath)), + join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), + join(binDir, "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), + ]; + + const deduped: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const key = + process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(candidate); + } + return deduped; +} + function findInPath(name: string): string | null { const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; for (const dir of (process.env.PATH ?? "").split(delimiter)) { @@ -188,6 +224,38 @@ function generatePkce(): { verifier: string; challenge: string } { return { verifier, challenge }; } +function resolvePlatform(): "WINDOWS" | "MACOS" | "LINUX" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "linux") { + return "LINUX"; + } + return "MACOS"; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url, + init, + timeoutMs, + }); + try { + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } finally { + await release(); + } +} + function buildAuthUrl(challenge: string, verifier: string): string { const { clientId } = resolveOAuthClientConfig(); const params = new URLSearchParams({ @@ -341,9 +409,13 @@ async function exchangeCodeForTokens( body.set("client_secret", clientSecret); } - const response = await fetch(TOKEN_URL, { + const response = await fetchWithTimeout(TOKEN_URL, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "*/*", + "User-Agent": "google-api-nodejs-client/9.15.1", + }, body, }); @@ -377,7 +449,7 @@ async function exchangeCodeForTokens( async function getUserEmail(accessToken: string): Promise { try { - const response = await fetch(USERINFO_URL, { + const response = await fetchWithTimeout(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (response.ok) { @@ -392,20 +464,25 @@ async function getUserEmail(accessToken: string): Promise { async function discoverProject(accessToken: string): Promise { const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const platform = resolvePlatform(); + const metadata = { + ideType: "ANTIGRAVITY", + platform, + pluginType: "GEMINI", + }; const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "gl-node/openclaw", + "X-Goog-Api-Client": `gl-node/${process.versions.node}`, + "Client-Metadata": JSON.stringify(metadata), }; const loadBody = { - cloudaicompanionProject: envProject, + ...(envProject ? { cloudaicompanionProject: envProject } : {}), metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: envProject, + ...metadata, + ...(envProject ? { duetProject: envProject } : {}), }, }; @@ -414,29 +491,46 @@ async function discoverProject(accessToken: string): Promise { cloudaicompanionProject?: string | { id?: string }; allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; } = {}; - - try { - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - }); - - if (!response.ok) { - const errorPayload = await response.json().catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - } else { - throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; + let loadError: Error | undefined; + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + activeEndpoint = endpoint; + loadError = undefined; + break; + } + loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + continue; } - } else { + data = (await response.json()) as typeof data; + activeEndpoint = endpoint; + loadError = undefined; + break; + } catch (err) { + loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); } - } catch (err) { - if (err instanceof Error) { - throw err; + } + + const hasLoadCodeAssistData = + Boolean(data.currentTier) || + Boolean(data.cloudaicompanionProject) || + Boolean(data.allowedTiers?.length); + if (!hasLoadCodeAssistData && loadError) { + if (envProject) { + return envProject; } - throw new Error("loadCodeAssist failed", { cause: err }); + throw loadError; } if (data.currentTier) { @@ -466,9 +560,7 @@ async function discoverProject(accessToken: string): Promise { const onboardBody: Record = { tierId, metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", + ...metadata, }, }; if (tierId !== TIER_FREE && envProject) { @@ -476,7 +568,7 @@ async function discoverProject(accessToken: string): Promise { (onboardBody.metadata as Record).duetProject = envProject; } - const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { method: "POST", headers, body: JSON.stringify(onboardBody), @@ -493,7 +585,7 @@ async function discoverProject(accessToken: string): Promise { }; if (!lro.done && lro.name) { - lro = await pollOperation(lro.name, headers); + lro = await pollOperation(activeEndpoint, lro.name, headers); } const projectId = lro.response?.cloudaicompanionProject?.id; @@ -539,12 +631,13 @@ function getDefaultTier( } async function pollOperation( + endpoint: string, operationName: string, headers: Record, ): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { for (let attempt = 0; attempt < 24; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 5000)); - const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { + const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { headers, }); if (!response.ok) { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index c9675901266f..bbd4efd7fc77 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index bd166510c7ad..cfaf35b137d1 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { - "google-auth-library": "^10.5.0" - }, - "devDependencies": { - "openclaw": "workspace:*" + "google-auth-library": "^10.6.1" }, "peerDependencies": { "openclaw": ">=2026.1.26" diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts new file mode 100644 index 000000000000..8823775cfd64 --- /dev/null +++ b/extensions/googlechat/src/channel.startup.test.ts @@ -0,0 +1,102 @@ +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; + +const hoisted = vi.hoisted(() => ({ + startGoogleChatMonitor: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + startGoogleChatMonitor: hoisted.startGoogleChatMonitor, + }; +}); + +import { googlechatPlugin } from "./channel.js"; + +function createStartAccountCtx(params: { + account: ResolvedGoogleChatAccount; + abortSignal: AbortSignal; + statusPatchSink?: (next: ChannelAccountSnapshot) => void; +}): ChannelGatewayContext { + const snapshot: ChannelAccountSnapshot = { + accountId: params.account.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.account.accountId, + account: params.account, + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + abortSignal: params.abortSignal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: (next) => { + Object.assign(snapshot, next); + params.statusPatchSink?.(snapshot); + }, + }; +} + +describe("googlechatPlugin gateway.startAccount", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then unregisters", async () => { + const unregister = vi.fn(); + hoisted.startGoogleChatMonitor.mockResolvedValue(unregister); + + const account: ResolvedGoogleChatAccount = { + accountId: "default", + enabled: true, + credentialSource: "inline", + credentials: {}, + config: { + webhookPath: "/googlechat", + webhookUrl: "https://example.com/googlechat", + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + }; + + const patches: ChannelAccountSnapshot[] = []; + const abort = new AbortController(); + const task = googlechatPlugin.gateway!.startAccount!( + createStartAccountCtx({ + account, + abortSignal: abort.signal, + statusPatchSink: (next) => patches.push({ ...next }), + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + let settled = false; + void task.then(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled).toBe(false); + + expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce(); + expect(unregister).not.toHaveBeenCalled(); + + abort.abort(); + await task; + + expect(unregister).toHaveBeenCalledOnce(); + expect(patches.some((entry) => entry.running === true)).toBe(true); + expect(patches.some((entry) => entry.running === false)).toBe(true); + }); +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 8022add55cad..0233cac70177 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -11,6 +11,8 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, @@ -198,8 +200,12 @@ export const googlechatPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy === "open") { warnings.push( `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, @@ -557,14 +563,20 @@ export const googlechatPlugin: ChannelPlugin = { webhookUrl: account.config.webhookUrl, statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), }); - return () => { - unregister?.(); - ctx.setStatus({ - accountId: account.accountId, - running: false, - lastStopAt: Date.now(), - }); - }; + // Keep the promise pending until abort (webhook mode is passive). + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + unregister?.(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); }, }, }; diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 6eec88abbe40..2a4e9935e2c0 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { isSenderAllowed } from "./monitor.js"; describe("isSenderAllowed", () => { - it("matches allowlist entries with raw email", () => { - expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true); + it("matches raw email entries only when dangerous name matching is enabled", () => { + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true); }); it("does not treat users/ entries as email allowlist (deprecated form)", () => { @@ -17,6 +18,8 @@ describe("isSenderAllowed", () => { }); it("rejects non-matching raw email entries", () => { - expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe( + false, + ); }); }); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index cee540058863..e31905a55ce3 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,15 +1,22 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + GROUP_POLICY_BLOCKED_LABEL, + createScopedPairingAccess, createReplyPrefixOptions, readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + isDangerousNameMatchingEnabled, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, + warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, resolveMentionGatingWithBypass, + resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk"; import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { @@ -283,6 +290,7 @@ export function isSenderAllowed( senderId: string, senderEmail: string | undefined, allowFrom: string[], + allowNameMatching = false, ) { if (allowFrom.includes("*")) { return true; @@ -301,8 +309,8 @@ export function isSenderAllowed( return normalizeUserId(withoutPrefix) === normalizedSenderId; } - // Raw email allowlist entries remain supported for usability. - if (normalizedEmail && isEmailLike(withoutPrefix)) { + // Raw email allowlist entries are a break-glass override. + if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { return withoutPrefix === normalizedEmail; } @@ -389,6 +397,11 @@ async function processMessageWithPipeline(params: { mediaMaxMb: number; }): Promise { const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params; + const pairing = createScopedPairingAccess({ + core, + channel: "googlechat", + accountId: account.accountId, + }); const space = event.space; const message = event.message; if (!space || !message) { @@ -405,6 +418,7 @@ async function processMessageWithPipeline(params: { const senderId = sender?.name ?? ""; const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? undefined; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const allowBots = account.config.allowBots === true; if (!allowBots) { @@ -426,8 +440,20 @@ async function processMessageWithPipeline(params: { return; } - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "googlechat", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space, + log: (message) => logVerbose(core, runtime, message), + }); const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, @@ -473,6 +499,7 @@ async function processMessageWithPipeline(params: { senderId, senderEmail, groupUsers.map((v) => String(v)), + allowNameMatching, ); if (!ok) { logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); @@ -483,16 +510,40 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dm?.policy ?? "pairing"; const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); + const normalizedGroupUsers = groupUsers.map((v) => String(v)); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : normalizedGroupUsers.length > 0 + ? "allowlist" + : "open"; const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const storeAllowFrom = !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth) - ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) + ? await pairing.readAllowFromStore().catch(() => []) : []; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const access = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: normalizedGroupUsers, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), + }); + const effectiveAllowFrom = access.effectiveAllowFrom; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); - const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; + const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); + const senderAllowedForCommands = isSenderAllowed( + senderId, + senderEmail, + commandAllowFrom, + allowNameMatching, + ); const commandAuthorized = shouldComputeAuth ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, @@ -528,47 +579,52 @@ async function processMessageWithPipeline(params: { } } + if (isGroup && access.decision !== "allow") { + logVerbose( + core, + runtime, + `drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`, + ); + return; + } + if (!isGroup) { - if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { + if (account.config.dm?.enabled === false) { logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); return; } - if (dmPolicy !== "open") { - const allowed = senderAllowedForCommands; - if (!allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "googlechat", - id: senderId, - meta: { name: senderName || undefined, email: senderEmail }, - }); - if (created) { - logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); - try { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: core.channel.pairing.buildPairingReply({ - channel: "googlechat", - idLine: `Your Google Chat user id: ${senderId}`, - code, - }), - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await pairing.upsertPairingRequest({ + id: senderId, + meta: { name: senderName || undefined, email: senderEmail }, + }); + if (created) { + logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); + try { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: core.channel.pairing.buildPairingReply({ + channel: "googlechat", + idLine: `Your Google Chat user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); } - } else { - logVerbose( - core, - runtime, - `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, - ); } - return; + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, + ); } + return; } } diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 926e012ddd14..e0e82149419a 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/imessage", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 00696414f236..a2b7bbde6300 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -18,6 +18,8 @@ import { resolveIMessageAccount, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -97,8 +99,12 @@ export const imessagePlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 39e2d8485f84..583b2cb04c11 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/irc", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw IRC channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 024f379c3d02..6993baa0ba70 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,11 +1,15 @@ import { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, - deleteAccountFromConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; import { @@ -134,8 +138,12 @@ export const ircPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy === "open") { warnings.push( '- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.', @@ -313,37 +321,23 @@ export const ircPlugin: ChannelPlugin = { lastError: null, }, buildChannelSummary: ({ account, snapshot }) => ({ - configured: snapshot.configured ?? false, + ...buildBaseChannelStatusSummary(snapshot), host: account.host, port: snapshot.port, tls: account.tls, nick: account.nick, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ cfg, account, timeoutMs }) => probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 14ce51b39a46..74a7ac363afd 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -45,6 +46,7 @@ export const IrcAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), host: z.string().optional(), port: z.number().int().min(1).max(65535).optional(), tls: z.boolean().optional(), @@ -62,15 +64,7 @@ export const IrcAccountSchemaBase = z channels: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/irc/src/inbound.policy.test.ts b/extensions/irc/src/inbound.policy.test.ts new file mode 100644 index 000000000000..c3c317c5000b --- /dev/null +++ b/extensions/irc/src/inbound.policy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./inbound.js"; + +describe("irc inbound policy", () => { + it("keeps DM allowlist merged with pairing-store entries", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + dmPolicy: "pairing", + }); + + expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); + }); + + it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: ["group-owner"], + storeAllowList: ["paired-user"], + dmPolicy: "pairing", + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not grant group access from pairing-store when groupAllowFrom is empty", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + dmPolicy: "pairing", + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual([]); + }); +}); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index abd523ed17cd..cb21b92c361d 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,7 +1,19 @@ import { + GROUP_POLICY_BLOCKED_LABEL, + createScopedPairingAccess, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, + isDangerousNameMatchingEnabled, + readStoreAllowFromForDmPolicy, resolveControlCommandGate, + resolveOutboundMediaUrls, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveEffectiveAllowFromLists, + warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -22,33 +34,41 @@ const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function resolveIrcEffectiveAllowlists(params: { + configAllowFrom: string[]; + configGroupAllowFrom: string[]; + storeAllowList: string[]; + dmPolicy: string; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ + allowFrom: params.configAllowFrom, + groupAllowFrom: params.configGroupAllowFrom, + storeAllowFrom: params.storeAllowList, + dmPolicy: params.dmPolicy, + // IRC intentionally requires explicit groupAllowFrom; do not fallback to allowFrom. + groupAllowFromFallbackToAllowFrom: false, + }); + return { effectiveAllowFrom, effectiveGroupAllowFrom }; +} + async function deliverIrcReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const text = params.payload.text ?? ""; - const mediaList = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - if (params.sendReply) { await params.sendReply(params.target, combined, params.payload.replyToId); } else { @@ -71,6 +91,11 @@ export async function handleIrcInbound(params: { }): Promise { const { message, account, config, runtime, connectedNick, statusSink } = params; const core = getIrcRuntime(); + const pairing = createScopedPairingAccess({ + core, + channel: CHANNEL_ID, + accountId: account.accountId, + }); const rawBody = message.text?.trim() ?? ""; if (!rawBody) { @@ -82,17 +107,32 @@ export async function handleIrcInbound(params: { const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "irc", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel, + log: (message) => runtime.log?.(message), + }); const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); - const storeAllowFrom = - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []); + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: CHANNEL_ID, + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }); const storeAllowList = normalizeIrcAllowlist(storeAllowFrom); const groupMatch = resolveIrcGroupMatch({ @@ -113,8 +153,12 @@ export async function handleIrcInbound(params: { const groupAllowFrom = directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({ + configAllowFrom, + configGroupAllowFrom, + storeAllowList, + dmPolicy, + }); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, @@ -124,6 +168,7 @@ export async function handleIrcInbound(params: { const senderAllowedForCommands = resolveIrcAllowlistMatch({ allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, message, + allowNameMatching, }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const commandGate = resolveControlCommandGate({ @@ -145,6 +190,7 @@ export async function handleIrcInbound(params: { message, outerAllowFrom: effectiveGroupAllowFrom, innerAllowFrom: groupAllowFrom, + allowNameMatching, }); if (!senderAllowed) { runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); @@ -159,11 +205,11 @@ export async function handleIrcInbound(params: { const dmAllowed = resolveIrcAllowlistMatch({ allowFrom: effectiveAllowFrom, message, + allowNameMatching, }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: CHANNEL_ID, + const { code, created } = await pairing.upsertPairingRequest({ id: senderDisplay.toLowerCase(), meta: { name: message.senderNick || undefined }, }); @@ -301,26 +347,22 @@ export async function handleIrcInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverIrcReply({ + payload, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverIrcReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, @@ -335,3 +377,7 @@ export async function handleIrcInbound(params: { }, }); } + +export const __testing = { + resolveIrcEffectiveAllowlists, +}; diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index d4dbec89db8c..4e07fa28abd8 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -39,13 +39,12 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.configured) { throw new Error( diff --git a/extensions/irc/src/normalize.test.ts b/extensions/irc/src/normalize.test.ts index a498ffaacd02..428f0015fd2e 100644 --- a/extensions/irc/src/normalize.test.ts +++ b/extensions/irc/src/normalize.test.ts @@ -30,6 +30,8 @@ describe("irc normalize", () => { }; expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org"); + expect(buildIrcAllowlistCandidates(message)).not.toContain("alice"); + expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice"); expect( resolveIrcAllowlistMatch({ allowFrom: ["alice!ident@example.org"], @@ -38,9 +40,16 @@ describe("irc normalize", () => { ).toBe(true); expect( resolveIrcAllowlistMatch({ - allowFrom: ["bob"], + allowFrom: ["alice"], message, }).allowed, ).toBe(false); + expect( + resolveIrcAllowlistMatch({ + allowFrom: ["alice"], + message, + allowNameMatching: true, + }).allowed, + ).toBe(true); }); }); diff --git a/extensions/irc/src/normalize.ts b/extensions/irc/src/normalize.ts index 89d135dbfd75..90b731dcbbfe 100644 --- a/extensions/irc/src/normalize.ts +++ b/extensions/irc/src/normalize.ts @@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string { return base; } -export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] { +export function buildIrcAllowlistCandidates( + message: IrcInboundMessage, + params?: { allowNameMatching?: boolean }, +): string[] { const nick = message.senderNick.trim().toLowerCase(); const user = message.senderUser?.trim().toLowerCase(); const host = message.senderHost?.trim().toLowerCase(); const candidates = new Set(); - if (nick) { + if (nick && params?.allowNameMatching === true) { candidates.add(nick); } if (nick && user) { @@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[ export function resolveIrcAllowlistMatch(params: { allowFrom: string[]; message: IrcInboundMessage; + allowNameMatching?: boolean; }): { allowed: boolean; source?: string } { const allowFrom = new Set( params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean), @@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: { if (allowFrom.has("*")) { return { allowed: true, source: "wildcard" }; } - const candidates = buildIrcAllowlistCandidates(params.message); + const candidates = buildIrcAllowlistCandidates(params.message, { + allowNameMatching: params.allowNameMatching, + }); for (const candidate of candidates) { if (allowFrom.has(candidate)) { return { allowed: true, source: candidate }; diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts index be3f65e617ec..4136466ca792 100644 --- a/extensions/irc/src/policy.test.ts +++ b/extensions/irc/src/policy.test.ts @@ -50,12 +50,29 @@ describe("irc policy", () => { }), ).toBe(false); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice!ident@example.org"], + innerAllowFrom: [], + }), + ).toBe(true); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice"], + innerAllowFrom: [], + }), + ).toBe(false); expect( resolveIrcGroupSenderAllowed({ groupPolicy: "allowlist", message, outerAllowFrom: ["alice"], innerAllowFrom: [], + allowNameMatching: true, }), ).toBe(true); }); diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts index 81828a5ac09a..356f0fae7d8b 100644 --- a/extensions/irc/src/policy.ts +++ b/extensions/irc/src/policy.ts @@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: { message: IrcInboundMessage; outerAllowFrom: string[]; innerAllowFrom: string[]; + allowNameMatching?: boolean; }): boolean { const policy = params.groupPolicy ?? "allowlist"; const inner = normalizeIrcAllowlist(params.innerAllowFrom); const outer = normalizeIrcAllowlist(params.outerAllowFrom); if (inner.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: inner, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } if (outer.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: outer, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } return policy === "open"; } diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index 2da3d31bafc4..03e2d3f5eb30 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -32,6 +32,11 @@ export type IrcNickServConfig = { export type IrcAccountConfig = { name?: string; enabled?: boolean; + /** + * Break-glass override: allow nick-only allowlist matching. + * Default behavior requires host/user-qualified identities. + */ + dangerouslyAllowNameMatching?: boolean; host?: string; port?: number; tls?: boolean; diff --git a/extensions/line/package.json b/extensions/line/package.json index 69907bd5ef78..03f640cf7af9 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/line", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index dbceacee7d98..b11bdc998702 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,10 +1,6 @@ -import type { - OpenClawConfig, - PluginRuntime, - ResolvedLineAccount, - RuntimeEnv, -} from "openclaw/plugin-sdk"; +import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -47,15 +43,40 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } +function resolveAccount( + resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], + cfg: OpenClawConfig, + accountId: string, +): ResolvedLineAccount { + const resolver = resolveLineAccount as unknown as (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => ResolvedLineAccount; + return resolver({ cfg, accountId }); +} + +async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{ + result: Awaited["logoutAccount"]>>>; + mocks: LineRuntimeMocks; +}> { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const account = resolveAccount(mocks.resolveLineAccount, params.cfg, params.accountId); + const result = await linePlugin.gateway!.logoutAccount!({ + accountId: params.accountId, + cfg: params.cfg, + account, + runtime: createRuntimeEnv(), + }); + return { result, mocks }; +} + describe("linePlugin gateway.logoutAccount", () => { beforeEach(() => { setLineRuntime(createRuntime().runtime); }); it("clears tokenFile/secretFile on default account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -64,38 +85,17 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: DEFAULT_ACCOUNT_ID, }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: DEFAULT_ACCOUNT_ID, - cfg, - account, - runtime: runtimeEnv, - }); - expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); }); it("clears tokenFile/secretFile on account logout", async () => { - const { runtime, mocks } = createRuntime(); - setLineRuntime(runtime); - const cfg: OpenClawConfig = { channels: { line: { @@ -108,31 +108,35 @@ describe("linePlugin gateway.logoutAccount", () => { }, }, }; - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - const resolveAccount = mocks.resolveLineAccount as unknown as (params: { - cfg: OpenClawConfig; - accountId?: string; - }) => ResolvedLineAccount; - const account = resolveAccount({ + const { result, mocks } = await runLogoutScenario({ cfg, accountId: "primary", }); - const result = await linePlugin.gateway!.logoutAccount!({ - accountId: "primary", + expect(result.cleared).toBe(true); + expect(result.loggedOut).toBe(true); + expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + }); + + it("does not write config when account has no token/secret fields", async () => { + const cfg: OpenClawConfig = { + channels: { + line: { + accounts: { + primary: { + name: "Primary", + }, + }, + }, + }, + }; + const { result, mocks } = await runLogoutScenario({ cfg, - account, - runtime: runtimeEnv, + accountId: "primary", }); - expect(result.cleared).toBe(true); + expect(result.cleared).toBe(false); expect(result.loggedOut).toBe(true); - expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); }); diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index abd1aedf17cb..812636113cb9 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -33,20 +33,11 @@ function createRuntime() { return { runtime, probeLineBot, monitorLineProvider }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { token: string; secret: string; - runtime: RuntimeEnv; + runtime: ReturnType; + abortSignal?: AbortSignal; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", @@ -66,7 +57,7 @@ function createStartAccountCtx(params: { }, cfg: {} as OpenClawConfig, runtime: params.runtime, - abortSignal: new AbortController().signal, + abortSignal: params.abortSignal ?? new AbortController().signal, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, getStatus: () => snapshot, setStatus: vi.fn(), @@ -114,14 +105,19 @@ describe("linePlugin gateway.startAccount", () => { const { runtime, monitorLineProvider } = createRuntime(); setLineRuntime(runtime); - await linePlugin.gateway!.startAccount!( + const abort = new AbortController(); + const task = linePlugin.gateway!.startAccount!( createStartAccountCtx({ token: "token", secret: "secret", runtime: createRuntimeEnv(), + abortSignal: abort.signal, }), ); + // Allow async internals (probeLineBot await) to flush + await new Promise((r) => setTimeout(r, 20)); + expect(monitorLineProvider).toHaveBeenCalledWith( expect.objectContaining({ channelAccessToken: "token", @@ -129,5 +125,8 @@ describe("linePlugin gateway.startAccount", () => { accountId: "default", }), ); + + abort.abort(); + await task; }); }); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cc30264e1e14..1c87ad8e2f3f 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,8 +1,11 @@ import { buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, @@ -161,9 +164,12 @@ export const linePlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) - ?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } @@ -590,17 +596,7 @@ export const linePlugin: ChannelPlugin = { } return issues; }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { @@ -655,7 +651,7 @@ export const linePlugin: ChannelPlugin = { ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`); - return getLineRuntime().channel.line.monitorLineProvider({ + const monitor = await getLineRuntime().channel.line.monitorLineProvider({ channelAccessToken: token, channelSecret: secret, accountId: account.accountId, @@ -664,6 +660,8 @@ export const linePlugin: ChannelPlugin = { abortSignal: ctx.abortSignal, webhookPath: account.config.webhookPath, }); + + return monitor; }, logoutAccount: async ({ accountId, cfg }) => { const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? ""; diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 7e9e24eade15..9252bdb7ea0c 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index f40d0351fec3..6a58118618cd 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; // NOTE: This extension is intended to be bundled with OpenClaw. // When running from source (tests/dev), OpenClaw internals live under src/. // When running from a built install, internals live under dist/ (no src/ tree). @@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; - const primary = api.config?.agents?.defaults?.model?.primary; + const defaultsModel = api.config?.agents?.defaults?.model; + const primary = + typeof defaultsModel === "string" + ? defaultsModel.trim() + : (defaultsModel?.primary?.trim() ?? undefined); const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; @@ -176,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { let tmpDir: string | null = null; try { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + tmpDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"), + ); const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index e6c7665735e6..ffbd1fad2b80 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.22", + "version": "2026.2.26", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 294e625ce2b0..78de735f8ef3 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("lobster plugin tool", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeAll(async () => { ({ createLobsterTool } = await import("./lobster-tool.js")); @@ -79,29 +74,7 @@ describe("lobster plugin tool", () => { }); afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); }); afterAll(async () => { @@ -156,17 +129,6 @@ describe("lobster plugin tool", () => { }); }; - const createWindowsShimFixture = async (params: { - shimPath: string; - scriptPath: string; - scriptToken: string; - }) => { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); - }; - it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -281,10 +243,10 @@ describe("lobster plugin tool", () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); - await createWindowsShimFixture({ + await createWindowsCmdShimFixture({ shimPath, scriptPath: shimScriptPath, - scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); process.env.PATHEXT = ".CMD;.EXE"; process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts new file mode 100644 index 000000000000..30f2dc81d1bb --- /dev/null +++ b/extensions/lobster/src/test-helpers.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; + +const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; + +export type PlatformPathEnvSnapshot = { + platformDescriptor: PropertyDescriptor | undefined; + env: Record; +}; + +export function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot { + return { + platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + env: { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + Pathext: process.env.Pathext, + }, + }; +} + +export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void { + if (snapshot.platformDescriptor) { + Object.defineProperty(process, "platform", snapshot.platformDescriptor); + } + + for (const key of PATH_ENV_KEYS) { + const value = snapshot.env[key]; + if (value === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = value; + } +} + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index 75f49f34b058..e3d791e36e4b 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -2,22 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); @@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => { }); afterEach(async () => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); @@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index fcbaf44e2d94..14085e49a921 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 7ffcb8e6cd98..cce28f2a65ef 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { @@ -10,9 +10,6 @@ "music-metadata": "^11.12.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3cd699f252c1..20dde4dc6ed6 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,6 +6,8 @@ import { formatPairingApproveHint, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; @@ -169,8 +171,12 @@ export const matrixPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 16de3bfd3e3e..6941af8af68b 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -1,9 +1,8 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk"; const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; @@ -22,85 +21,6 @@ function resolvePluginRoot(): string { return path.resolve(currentDir, "..", ".."); } -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout(params: { - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; -}): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - export async function ensureMatrixSdkInstalled(params: { runtime: RuntimeEnv; confirm?: (message: string) => Promise; @@ -121,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runFixedCommandWithTimeout({ + const result = await runPluginCommandWithTimeout({ argv: command, cwd: root, timeoutMs: 300_000, diff --git a/extensions/matrix/src/matrix/monitor/access-policy.ts b/extensions/matrix/src/matrix/monitor/access-policy.ts new file mode 100644 index 000000000000..e937ba818483 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/access-policy.ts @@ -0,0 +1,127 @@ +import { + formatAllowlistMatchMeta, + issuePairingChallenge, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "openclaw/plugin-sdk"; +import { + normalizeMatrixAllowList, + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, +} from "./allowlist.js"; + +type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled"; +type MatrixGroupPolicy = "open" | "allowlist" | "disabled"; + +export async function resolveMatrixAccessState(params: { + isDirectMessage: boolean; + resolvedAccountId: string; + dmPolicy: MatrixDmPolicy; + groupPolicy: MatrixGroupPolicy; + allowFrom: string[]; + groupAllowFrom: Array; + senderId: string; + readStoreForDmPolicy: (provider: string, accountId: string) => Promise; +}) { + const storeAllowFrom = params.isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "matrix", + accountId: params.resolvedAccountId, + dmPolicy: params.dmPolicy, + readStore: params.readStoreForDmPolicy, + }) + : []; + const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom); + const senderGroupPolicy = + params.groupPolicy === "disabled" + ? "disabled" + : normalizedGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: !params.isDirectMessage, + dmPolicy: params.dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMatrixAllowListMatches({ + allowList: normalizeMatrixAllowList(allowFrom), + userId: params.senderId, + }), + }); + const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom); + const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom); + return { + access, + effectiveAllowFrom, + effectiveGroupAllowFrom, + groupAllowConfigured: effectiveGroupAllowFrom.length > 0, + }; +} + +export async function enforceMatrixDirectMessageAccess(params: { + dmEnabled: boolean; + dmPolicy: MatrixDmPolicy; + accessDecision: "allow" | "block" | "pairing"; + senderId: string; + senderName: string; + effectiveAllowFrom: string[]; + upsertPairingRequest: (input: { + id: string; + meta?: Record; + }) => Promise<{ + code: string; + created: boolean; + }>; + sendPairingReply: (text: string) => Promise; + logVerboseMessage: (message: string) => void; +}): Promise { + if (!params.dmEnabled) { + return false; + } + if (params.accessDecision === "allow") { + return true; + } + const allowMatch = resolveMatrixAllowListMatch({ + allowList: params.effectiveAllowFrom, + userId: params.senderId, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (params.accessDecision === "pairing") { + await issuePairingChallenge({ + channel: "matrix", + senderId: params.senderId, + senderIdLine: `Matrix user id: ${params.senderId}`, + meta: { name: params.senderName }, + upsertPairingRequest: params.upsertPairingRequest, + buildReplyText: ({ code }) => + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + sendPairingReply: params.sendPairingReply, + onCreated: () => { + params.logVerboseMessage( + `matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`, + ); + }, + onReplyError: (err) => { + params.logVerboseMessage( + `matrix pairing reply failed for ${params.senderId}: ${String(err)}`, + ); + }, + }); + return false; + } + params.logVerboseMessage( + `matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`, + ); + return false; +} diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts new file mode 100644 index 000000000000..3754cfd178e7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -0,0 +1,141 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixAuth } from "../client.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import type { MatrixRawEvent } from "./types.js"; + +const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../send.js", () => ({ + sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args), +})); + +describe("registerMatrixMonitorEvents", () => { + beforeEach(() => { + sendReadReceiptMatrixMock.mockClear(); + }); + + function createHarness(options?: { getUserId?: ReturnType }) { + const handlers = new Map void>(); + const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org"); + const client = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + getUserId, + crypto: undefined, + } as unknown as MatrixClient; + + const onRoomMessage = vi.fn(); + const logVerboseMessage = vi.fn(); + const logger = { + warn: vi.fn(), + } as unknown as RuntimeLogger; + + registerMatrixMonitorEvents({ + client, + auth: { encryption: false } as MatrixAuth, + logVerboseMessage, + warnedEncryptedRooms: new Set(), + warnedCryptoMissingRooms: new Set(), + logger, + formatNativeDependencyHint: (() => + "") as PluginRuntime["system"]["formatNativeDependencyHint"], + onRoomMessage, + }); + + const roomMessageHandler = handlers.get("room.message"); + if (!roomMessageHandler) { + throw new Error("missing room.message handler"); + } + + return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage }; + } + + it("sends read receipt immediately for non-self messages", async () => { + const { client, onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e1", + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client); + }); + }); + + it("does not send read receipts for self messages", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e2", + sender: "@bot:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); + + it("skips receipt when message lacks sender or event id", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); + + it("caches self user id across messages", async () => { + const { getUserId, roomMessageHandler } = createHarness(); + const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent; + const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", first); + roomMessageHandler("!room:example.org", second); + + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + }); + + it("logs and continues when sending read receipt fails", async () => { + sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom")); + const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness(); + const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("matrix: early read receipt failed"), + ); + }); + }); + + it("skips read receipts if self-user lookup fails", async () => { + const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({ + getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")), + }); + const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 60bbe574add9..279517d521d8 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,9 +1,36 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { MatrixAuth } from "../client.js"; +import { sendReadReceiptMatrix } from "../send.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; +function createSelfUserIdResolver(client: Pick) { + let selfUserId: string | undefined; + let selfUserIdLookup: Promise | undefined; + + return async (): Promise => { + if (selfUserId) { + return selfUserId; + } + if (!selfUserIdLookup) { + selfUserIdLookup = client + .getUserId() + .then((userId) => { + selfUserId = userId; + return userId; + }) + .catch(() => undefined) + .finally(() => { + if (!selfUserId) { + selfUserIdLookup = undefined; + } + }); + } + return await selfUserIdLookup; + }; +} + export function registerMatrixMonitorEvents(params: { client: MatrixClient; auth: MatrixAuth; @@ -25,7 +52,26 @@ export function registerMatrixMonitorEvents(params: { onRoomMessage, } = params; - client.on("room.message", onRoomMessage); + const resolveSelfUserId = createSelfUserIdResolver(client); + client.on("room.message", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id; + const senderId = event?.sender; + if (eventId && senderId) { + void (async () => { + const currentSelfUserId = await resolveSelfUserId(); + if (!currentSelfUserId || senderId === currentSelfUserId) { + return; + } + await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => { + logVerboseMessage( + `matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`, + ); + }); + })(); + } + + onRoomMessage(roomId, event); + }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id ?? "unknown"; diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts new file mode 100644 index 000000000000..49ae73233175 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -0,0 +1,142 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { EventType, type MatrixRawEvent } from "./types.js"; + +describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { + it("stores sender-labeled BodyForAgent for group thread messages", async () => { + const recordInboundSession = vi.fn().mockResolvedValue(undefined); + const formatInboundEnvelope = vi + .fn() + .mockImplementation((params: { senderLabel?: string; body: string }) => params.body); + const finalizeInboundContext = vi + .fn() + .mockImplementation((ctx: Record) => ctx); + + const core = { + channel: { + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + }, + routing: { + resolveAgentRoute: vi.fn().mockReturnValue({ + agentId: "main", + accountId: undefined, + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + }), + }, + session: { + resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), + readSessionUpdatedAt: vi.fn().mockReturnValue(123), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), + formatInboundEnvelope, + formatAgentEnvelope: vi + .fn() + .mockImplementation((params: { body: string }) => params.body), + finalizeInboundContext, + resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), + createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }), + withReplyDispatcher: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }), + }, + commands: { + shouldHandleTextCommands: vi.fn().mockReturnValue(true), + }, + text: { + hasControlCommand: vi.fn().mockReturnValue(false), + resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + }, + }, + system: { + enqueueSystemEvent: vi.fn(), + }, + } as unknown as PluginRuntime; + + const runtime = { + error: vi.fn(), + } as unknown as RuntimeEnv; + const logger = { + info: vi.fn(), + warn: vi.fn(), + } as unknown as RuntimeLogger; + const logVerboseMessage = vi.fn(); + + const client = { + getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), + } as unknown as MatrixClient; + + const handler = createMatrixRoomMessageHandler({ + client, + core, + cfg: {}, + runtime, + logger, + logVerboseMessage, + allowFrom: [], + roomsConfig: undefined, + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "first", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + startupMs: Date.now(), + startupGraceMs: 60_000, + directTracker: { + isDirectMessage: vi.fn().mockResolvedValue(false), + }, + getRoomInfo: vi.fn().mockResolvedValue({ + name: "Dev Room", + canonicalAlias: "#dev:matrix.example.org", + altAliases: [], + }), + getMemberDisplayName: vi.fn().mockResolvedValue("Bu"), + accountId: undefined, + }); + + const event = { + type: EventType.RoomMessage, + event_id: "$event1", + sender: "@bu:matrix.example.org", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "show me my commits", + "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread-root", + }, + }, + } as unknown as MatrixRawEvent; + + await handler("!room:example.org", event); + + expect(formatInboundEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + chatType: "channel", + senderLabel: "Bu (bu)", + }), + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + ChatType: "thread", + BodyForAgent: "Bu (bu): show me my commits", + }), + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index d884879001ef..fc441b83f9a5 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,5 +1,7 @@ import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { + DEFAULT_ACCOUNT_ID, + createScopedPairingAccess, createReplyPrefixOptions, createTypingCallbacks, formatAllowlistMatchMeta, @@ -18,17 +20,18 @@ import { parsePollStartContent, type PollStartContent, } from "../poll-types.js"; -import { - reactMatrixMessage, - sendMessageMatrix, - sendReadReceiptMatrix, - sendTypingMatrix, -} from "../send.js"; +import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; +import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, resolveMatrixAllowListMatches, } from "./allowlist.js"; +import { + resolveMatrixBodyForAgent, + resolveMatrixInboundSenderLabel, + resolveMatrixSenderUsername, +} from "./inbound-body.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions } from "./mentions.js"; @@ -96,6 +99,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam getMemberDisplayName, accountId, } = params; + const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID; + const pairing = createScopedPairingAccess({ + core, + channel: "matrix", + accountId: resolvedAccountId, + }); return async (roomId: string, event: MatrixRawEvent) => { try { @@ -218,62 +227,42 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); - const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); + const senderUsername = resolveMatrixSenderUsername(senderId); + const senderLabel = resolveMatrixInboundSenderLabel({ + senderName, + senderId, + senderUsername, + }); const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); - const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; + const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } = + await resolveMatrixAccessState({ + isDirectMessage, + resolvedAccountId, + dmPolicy, + groupPolicy, + allowFrom, + groupAllowFrom, + senderId, + readStoreForDmPolicy: pairing.readStoreForDmPolicy, + }); if (isDirectMessage) { - if (!dmEnabled || dmPolicy === "disabled") { + const allowedDirectMessage = await enforceMatrixDirectMessageAccess({ + dmEnabled, + dmPolicy, + accessDecision: access.decision, + senderId, + senderName, + effectiveAllowFrom, + upsertPairingRequest: pairing.upsertPairingRequest, + sendPairingReply: async (text) => { + await sendMessageMatrix(`room:${roomId}`, text, { client }); + }, + logVerboseMessage, + }); + if (!allowedDirectMessage) { return; } - if (dmPolicy !== "open") { - const allowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveAllowFrom, - userId: senderId, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "matrix", - id: senderId, - meta: { name: senderName }, - }); - if (created) { - logVerboseMessage( - `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, - ); - try { - await sendMessageMatrix( - `room:${roomId}`, - [ - "OpenClaw: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "openclaw pairing approve matrix ", - ].join("\n"), - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } - } - } - if (dmPolicy !== "pairing") { - logVerboseMessage( - `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return; - } - } } const roomUsers = roomConfig?.users ?? []; @@ -291,7 +280,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } - if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") { const groupAllowMatch = resolveMatrixAllowListMatch({ allowList: effectiveGroupAllowFrom, userId: senderId, @@ -503,19 +492,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam storePath, sessionKey: route.sessionKey, }); - const body = core.channel.reply.formatAgentEnvelope({ + const body = core.channel.reply.formatInboundEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: eventTs ?? undefined, previousTimestamp, envelope: envelopeOptions, body: textWithId, + chatType: isDirectMessage ? "direct" : "channel", + senderLabel, }); const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, - BodyForAgent: bodyText, + BodyForAgent: resolveMatrixBodyForAgent({ + isDirectMessage, + bodyText, + senderLabel, + }), RawBody: bodyText, CommandBody: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, @@ -526,7 +521,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ConversationLabel: envelopeFrom, SenderName: senderName, SenderId: senderId, - SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), + SenderUsername: senderUsername, GroupSubject: isRoom ? (roomName ?? roomId) : undefined, GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, @@ -602,14 +597,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - if (messageId) { - sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { - logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, - ); - }); - } - let didSendReply = false; const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, @@ -648,6 +635,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { await deliverMatrixReplies({ replies: [payload], @@ -665,21 +653,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onError: (err, info) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - onModelSelected, + onSettled: () => { + markDispatchIdle(); }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected, + }, + }), }); - markDispatchIdle(); if (!queuedFinal) { return; } diff --git a/extensions/matrix/src/matrix/monitor/inbound-body.test.ts b/extensions/matrix/src/matrix/monitor/inbound-body.test.ts new file mode 100644 index 000000000000..8b5c63c89a99 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-body.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + resolveMatrixBodyForAgent, + resolveMatrixInboundSenderLabel, + resolveMatrixSenderUsername, +} from "./inbound-body.js"; + +describe("resolveMatrixSenderUsername", () => { + it("extracts localpart without leading @", () => { + expect(resolveMatrixSenderUsername("@bu:matrix.example.org")).toBe("bu"); + }); +}); + +describe("resolveMatrixInboundSenderLabel", () => { + it("uses provided senderUsername when present", () => { + expect( + resolveMatrixInboundSenderLabel({ + senderName: "Bu", + senderId: "@bu:matrix.example.org", + senderUsername: "BU_CUSTOM", + }), + ).toBe("Bu (BU_CUSTOM)"); + }); + + it("includes sender username when it differs from display name", () => { + expect( + resolveMatrixInboundSenderLabel({ + senderName: "Bu", + senderId: "@bu:matrix.example.org", + }), + ).toBe("Bu (bu)"); + }); + + it("falls back to sender username when display name is blank", () => { + expect( + resolveMatrixInboundSenderLabel({ + senderName: " ", + senderId: "@zhang:matrix.example.org", + }), + ).toBe("zhang"); + }); + + it("falls back to sender id when username cannot be parsed", () => { + expect( + resolveMatrixInboundSenderLabel({ + senderName: "", + senderId: "matrix-user-without-colon", + }), + ).toBe("matrix-user-without-colon"); + }); +}); + +describe("resolveMatrixBodyForAgent", () => { + it("keeps direct message body unchanged", () => { + expect( + resolveMatrixBodyForAgent({ + isDirectMessage: true, + bodyText: "show me my commits", + senderLabel: "Bu (bu)", + }), + ).toBe("show me my commits"); + }); + + it("prefixes non-direct message body with sender label", () => { + expect( + resolveMatrixBodyForAgent({ + isDirectMessage: false, + bodyText: "show me my commits", + senderLabel: "Bu (bu)", + }), + ).toBe("Bu (bu): show me my commits"); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/inbound-body.ts b/extensions/matrix/src/matrix/monitor/inbound-body.ts new file mode 100644 index 000000000000..48ad8d31e791 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/inbound-body.ts @@ -0,0 +1,28 @@ +export function resolveMatrixSenderUsername(senderId: string): string | undefined { + const username = senderId.split(":")[0]?.replace(/^@/, "").trim(); + return username ? username : undefined; +} + +export function resolveMatrixInboundSenderLabel(params: { + senderName: string; + senderId: string; + senderUsername?: string; +}): string { + const senderName = params.senderName.trim(); + const senderUsername = params.senderUsername ?? resolveMatrixSenderUsername(params.senderId); + if (senderName && senderUsername && senderName !== senderUsername) { + return `${senderName} (${senderUsername})`; + } + return senderName || senderUsername || params.senderId; +} + +export function resolveMatrixBodyForAgent(params: { + isDirectMessage: boolean; + bodyText: string; + senderLabel: string; +}): string { + if (params.isDirectMessage) { + return params.bodyText; + } + return `${params.senderLabel}: ${params.bodyText}`; +} diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index df6d87fad487..936eabdd3467 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,13 @@ -import { format } from "node:util"; -import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk"; +import { + createLoggerBackedRuntime, + GROUP_POLICY_BLOCKED_LABEL, + mergeAllowlist, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -40,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; @@ -242,8 +243,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client, opts.accountId); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.matrix !== undefined, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "matrix", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => logVerboseMessage(message), + }); const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b58..dfbfbabb8af2 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => { ); }); + it("skips reasoning-only replies with Reasoning prefix", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, + { text: "Here is the answer.", replyToId: "r2" }, + ], + roomId: "room:reason", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "first", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); + }); + + it("skips reasoning-only replies with thinking tags", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "internal chain of thought", replyToId: "r1" }, + { text: " more reasoning ", replyToId: "r2" }, + { text: "hidden", replyToId: "r3" }, + { text: "Visible reply", replyToId: "r4" }, + ], + roomId: "room:tags", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); + }); + + it("delivers all replies when none are reasoning-only", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "First answer", replyToId: "r1" }, + { text: "Second answer", replyToId: "r2" }, + ], + roomId: "room:normal", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + }); + it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 643e95cd4134..c86c7dde688c 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } + // Skip pure reasoning messages so internal thinking traces are never delivered. + if (reply.text && isReasoningOnlyMessage(reply.text)) { + logVerbose("matrix reply is reasoning-only; skipping"); + continue; + } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; const rawText = reply.text ?? ""; @@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: { } } } + +const REASONING_PREFIX = "Reasoning:\n"; +const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; + +/** + * Detect messages that contain only reasoning/thinking content and no user-facing answer. + * These are emitted by the agent when `includeReasoning` is active but should not + * be forwarded to channels that do not support a dedicated reasoning lane. + */ +function isReasoningOnlyMessage(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith(REASONING_PREFIX)) { + return true; + } + if (THINKING_TAG_RE.test(trimmed)) { + return true; + } + return false; +} diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts new file mode 100644 index 000000000000..aa4765eaab34 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("enqueueSend", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("serializes sends per room", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + events.push("end1"); + return "one"; + }); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + events.push("end2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + expect(events).toEqual(["start1"]); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2); + expect(events).toEqual(["start1"]); + + gate.resolve(); + await first; + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1); + expect(events).toEqual(["start1", "end1"]); + await vi.advanceTimersByTimeAsync(1); + await second; + expect(events).toEqual(["start1", "end1", "start2", "end2"]); + }); + + it("does not serialize across different rooms", async () => { + const events: string[] = []; + + const a = enqueueSend("!a:example.org", async () => { + events.push("a"); + return "a"; + }); + const b = enqueueSend("!b:example.org", async () => { + events.push("b"); + return "b"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await Promise.all([a, b]); + expect(events.sort()).toEqual(["a", "b"]); + }); + + it("continues queue after failures", async () => { + const first = enqueueSend("!room:example.org", async () => { + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected first queue item to fail"); + } + expect(firstResult.error).toBeInstanceOf(Error); + expect(firstResult.error.message).toBe("boom"); + + const second = enqueueSend("!room:example.org", async () => "ok"); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await expect(second).resolves.toBe("ok"); + }); + + it("continues queued work when the head task fails", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + expect(events).toEqual(["start1"]); + + gate.resolve(); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected head queue item to fail"); + } + expect(firstResult.error).toBeInstanceOf(Error); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["start1", "start2"]); + }); + + it("supports custom gap and delay injection", async () => { + const events: string[] = []; + const delayFn = vi.fn(async (_ms: number) => {}); + + const first = enqueueSend( + "!room:example.org", + async () => { + events.push("first"); + return "one"; + }, + { gapMs: 7, delayFn }, + ); + const second = enqueueSend( + "!room:example.org", + async () => { + events.push("second"); + return "two"; + }, + { gapMs: 7, delayFn }, + ); + + await expect(first).resolves.toBe("one"); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["first", "second"]); + expect(delayFn).toHaveBeenCalledTimes(2); + expect(delayFn).toHaveBeenNthCalledWith(1, 7); + expect(delayFn).toHaveBeenNthCalledWith(2, 7); + }); +}); diff --git a/extensions/matrix/src/matrix/send-queue.ts b/extensions/matrix/src/matrix/send-queue.ts new file mode 100644 index 000000000000..daf5e40931e8 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.ts @@ -0,0 +1,44 @@ +export const DEFAULT_SEND_GAP_MS = 150; + +type MatrixSendQueueOptions = { + gapMs?: number; + delayFn?: (ms: number) => Promise; +}; + +// Serialize sends per room to preserve Matrix delivery order. +const roomQueues = new Map>(); + +export async function enqueueSend( + roomId: string, + fn: () => Promise, + options?: MatrixSendQueueOptions, +): Promise { + const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS; + const delayFn = options?.delayFn ?? delay; + const previous = roomQueues.get(roomId) ?? Promise.resolve(); + + const next = previous + .catch(() => {}) + .then(async () => { + await delayFn(gapMs); + return await fn(); + }); + + const queueMarker = next.then( + () => {}, + () => {}, + ); + roomQueues.set(roomId, queueMarker); + + queueMarker.finally(() => { + if (roomQueues.get(roomId) === queueMarker) { + roomQueues.delete(roomId); + } + }); + + return await next; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index b531b55dcdaf..dd72ec2883b3 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -2,6 +2,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "openclaw/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; +import { enqueueSend } from "./send-queue.js"; import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; import { buildReplyRelation, @@ -49,103 +50,105 @@ export async function sendMessageMatrix( }); try { const roomId = await resolveMatrixRoomId(client, to); - const cfg = getCore().config.loadConfig(); - const tableMode = getCore().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix", - accountId: opts.accountId, - }); - const convertedMessage = getCore().channel.text.convertMarkdownTables( - trimmedMessage, - tableMode, - ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); - const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); - const chunks = getCore().channel.text.chunkMarkdownTextWithMode( - convertedMessage, - chunkLimit, - chunkMode, - ); - const threadId = normalizeThreadId(opts.threadId); - const relation = threadId - ? buildThreadRelation(threadId, opts.replyToId) - : buildReplyRelation(opts.replyToId); - const sendContent = async (content: MatrixOutboundContent) => { - // @vector-im/matrix-bot-sdk uses sendMessage differently - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; - - let lastMessageId = ""; - if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { - contentType: media.contentType, - filename: media.fileName, - }); - const durationMs = await resolveMediaDurationMs({ - buffer: media.buffer, - contentType: media.contentType, - fileName: media.fileName, - kind: media.kind, + return await enqueueSend(roomId, async () => { + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, }); - const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); - const { useVoice } = resolveMatrixVoiceDecision({ - wantsVoice: opts.audioAsVoice === true, - contentType: media.contentType, - fileName: media.fileName, - }); - const msgtype = useVoice ? MsgType.Audio : baseMsgType; - const isImage = msgtype === MsgType.Image; - const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) - : undefined; - const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); - const content = buildMediaContent({ - msgtype, - body, - url: uploaded.url, - file: uploaded.file, - filename: media.fileName, - mimetype: media.contentType, - size: media.buffer.byteLength, - durationMs, - relation, - isVoice: useVoice, - imageInfo, - }); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - const textChunks = useVoice ? chunks : rest; - const followupRelation = threadId ? relation : undefined; - for (const chunk of textChunks) { - const text = chunk.trim(); - if (!text) { - continue; - } - const followup = buildTextContent(text, followupRelation); - const followupEventId = await sendContent(followup); - lastMessageId = followupEventId ?? lastMessageId; - } - } else { - for (const chunk of chunks.length ? chunks : [""]) { - const text = chunk.trim(); - if (!text) { - continue; - } - const content = buildTextContent(text, relation); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + // @vector-im/matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(opts.accountId); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind, + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) { + continue; + } + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) { + continue; + } + const content = buildTextContent(text, relation); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + } } - } - return { - messageId: lastMessageId || "unknown", - roomId, - }; + return { + messageId: lastMessageId || "unknown", + roomId, + }; + }); } finally { if (stopOnDone) { client.stop(); diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index be6206d71f90..91cf1986c31b 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Mattermost channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index cd60f4fe65a7..9cb5df2b8465 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -54,6 +54,25 @@ describe("mattermostPlugin", () => { resetMattermostReactionBotUserCacheForTests(); }); + const runReactAction = async (params: Record, fetchMode: "add" | "remove") => { + const cfg = createMattermostTestConfig(); + const fetchImpl = createMattermostReactionFetchMock({ + mode: fetchMode, + postId: "POST1", + emojiName: "thumbsup", + }); + + return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { + return await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "react", + params, + cfg, + accountId: "default", + } as any); + }); + }; + it("exposes react when mattermost is configured", () => { const cfg: OpenClawConfig = { channels: { @@ -152,50 +171,31 @@ describe("mattermostPlugin", () => { }); it("handles react by calling Mattermost reactions API", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); - - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup" }, - cfg, - accountId: "default", - } as any); - - return result; - }); + const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add"); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); expect(result?.details).toEqual({}); }); it("only treats boolean remove flag as removal", async () => { - const cfg = createMattermostTestConfig(); - const fetchImpl = createMattermostReactionFetchMock({ - mode: "add", - postId: "POST1", - emojiName: "thumbsup", - }); + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: "true" }, + "add", + ); - const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => { - const result = await mattermostPlugin.actions?.handleAction?.({ - channel: "mattermost", - action: "react", - params: { messageId: "POST1", emoji: "thumbsup", remove: "true" }, - cfg, - accountId: "default", - } as any); + expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); + }); - return result; - }); + it("removes reaction when remove flag is boolean true", async () => { + const result = await runReactAction( + { messageId: "POST1", emoji: "thumbsup", remove: true }, + "remove", + ); - expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]); + expect(result?.content).toEqual([ + { type: "text", text: "Removed reaction :thumbsup: from POST1" }, + ]); + expect(result?.details).toEqual({}); }); }); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 3935d5f205ee..5053026f49af 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -6,6 +6,8 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -228,8 +230,12 @@ export const mattermostPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 7628613a16b1..bb0d99e56675 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index f0a0fd26adc5..826212c9eb8c 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -58,7 +58,7 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string { return `${normalized}/api/v4${suffix}`; } -async function readMattermostError(res: Response): Promise { +export async function readMattermostError(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { const data = (await res.json()) as { message?: string } | undefined; diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts new file mode 100644 index 000000000000..2b968c5f1175 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -0,0 +1,58 @@ +import { resolveAllowlistMatchSimple, resolveEffectiveAllowFromLists } from "openclaw/plugin-sdk"; + +export function normalizeMattermostAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + return trimmed + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +export function normalizeMattermostAllowList(entries: Array): string[] { + const normalized = entries + .map((entry) => normalizeMattermostAllowEntry(String(entry))) + .filter(Boolean); + return Array.from(new Set(normalized)); +} + +export function resolveMattermostEffectiveAllowFromLists(params: { + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + dmPolicy?: string | null; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + return resolveEffectiveAllowFromLists({ + allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []), + storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []), + dmPolicy: params.dmPolicy, + }); +} + +export function isMattermostSenderAllowed(params: { + senderId: string; + senderName?: string; + allowFrom: string[]; + allowNameMatching?: boolean; +}): boolean { + const allowFrom = normalizeMattermostAllowList(params.allowFrom); + if (allowFrom.length === 0) { + return false; + } + const match = resolveAllowlistMatchSimple({ + allowFrom, + senderId: normalizeMattermostAllowEntry(params.senderId), + senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined, + allowNameMatching: params.allowNameMatching, + }); + return match.allowed; +} diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index c423513a6a2f..d645d563d38c 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -1,4 +1,8 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + formatInboundFromLabel as formatInboundFromLabelShared, + resolveThreadSessionKeys as resolveThreadSessionKeysShared, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk"; export type ResponsePrefixContext = { @@ -15,27 +19,7 @@ export function extractShortModelName(fullModel: string): string { return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); } -export function formatInboundFromLabel(params: { - isGroup: boolean; - groupLabel?: string; - groupId?: string; - directLabel: string; - directId?: string; - groupFallback?: string; -}): string { - if (params.isGroup) { - const label = params.groupLabel?.trim() || params.groupFallback || "Group"; - const id = params.groupId?.trim(); - return id ? `${label} id:${id}` : label; - } - - const directLabel = params.directLabel.trim(); - const directId = params.directId?.trim(); - if (!directId || directId === directLabel) { - return directLabel; - } - return `${directLabel} id:${directId}`; -} +export const formatInboundFromLabel = formatInboundFromLabelShared; function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); @@ -81,13 +65,8 @@ export function resolveThreadSessionKeys(params: { parentSessionKey?: string; useSuffix?: boolean; }): { sessionKey: string; parentSessionKey?: string } { - const threadId = (params.threadId ?? "").trim(); - if (!threadId) { - return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; - } - const useSuffix = params.useSuffix ?? true; - const sessionKey = useSuffix - ? `${params.baseSessionKey}:thread:${threadId}` - : params.baseSessionKey; - return { sessionKey, parentSessionKey: params.parentSessionKey }; + return resolveThreadSessionKeysShared({ + ...params, + normalizeThreadId: (threadId) => threadId, + }); } diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts new file mode 100644 index 000000000000..9b6a296a34ef --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -0,0 +1,59 @@ +import { resolveControlCommandGate } from "openclaw/plugin-sdk"; +import { describe, expect, it } from "vitest"; +import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js"; + +describe("mattermost monitor authz", () => { + it("keeps DM allowlist merged with pairing-store entries", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + }); + + it("uses explicit groupAllowFrom without pairing-store inheritance", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + groupAllowFrom: ["@group-owner"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not inherit pairing-store entries into group allowlist", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "pairing", + allowFrom: ["@trusted-user"], + storeAllowFrom: ["user:attacker"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]); + }); + + it("does not auto-authorize DM commands in open mode without allowlists", () => { + const resolved = resolveMattermostEffectiveAllowFromLists({ + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: [], + }); + + const commandGate = resolveControlCommandGate({ + useAccessGroups: true, + authorizers: [ + { configured: resolved.effectiveAllowFrom.length > 0, allowed: false }, + { configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false }, + ], + allowTextCommands: true, + hasControlCommand: true, + }); + + expect(commandGate.commandAuthorized).toBe(false); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index b2c921b155d8..b66c15812aec 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,6 +7,8 @@ import type { } from "openclaw/plugin-sdk"; import { buildAgentMediaPayload, + DM_GROUP_ACCESS_REASON, + createScopedPairingAccess, createReplyPrefixOptions, createTypingCallbacks, logInboundDrop, @@ -15,8 +17,14 @@ import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, + isDangerousNameMatchingEnabled, resolveControlCommandGate, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveChannelMediaMaxBytes, + warnMissingProviderGroupPolicyFallbackOnce, type HistoryEntry, } from "openclaw/plugin-sdk"; import { getMattermostRuntime } from "../runtime.js"; @@ -32,6 +40,7 @@ import { type MattermostPost, type MattermostUser, } from "./client.js"; +import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; import { createDedupeCache, formatInboundFromLabel, @@ -57,7 +66,6 @@ export type MonitorMattermostOpts = { webSocketFactory?: MattermostWebSocketFactory; }; -type FetchLike = (input: URL | RequestInfo, init?: RequestInit) => Promise; type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; type MattermostReaction = { @@ -126,45 +134,6 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" { return "channel"; } -function normalizeAllowEntry(entry: string): string { - const trimmed = entry.trim(); - if (!trimmed) { - return ""; - } - if (trimmed === "*") { - return "*"; - } - return trimmed - .replace(/^(mattermost|user):/i, "") - .replace(/^@/, "") - .toLowerCase(); -} - -function normalizeAllowList(entries: Array): string[] { - const normalized = entries.map((entry) => normalizeAllowEntry(String(entry))).filter(Boolean); - return Array.from(new Set(normalized)); -} - -function isSenderAllowed(params: { - senderId: string; - senderName?: string; - allowFrom: string[]; -}): boolean { - const allowFrom = params.allowFrom; - if (allowFrom.length === 0) { - return false; - } - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = normalizeAllowEntry(params.senderId); - const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; - return allowFrom.some( - (entry) => - entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), - ); -} - type MattermostMediaInfo = { path: string; contentType?: string; @@ -203,6 +172,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, accountId: opts.accountId, }); + const pairing = createScopedPairingAccess({ + core, + channel: "mattermost", + accountId: account.accountId, + }); + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const botToken = opts.botToken?.trim() || account.botToken?.trim(); if (!botToken) { throw new Error( @@ -242,12 +217,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, ); const channelHistories = new Map(); - - const fetchWithAuth: FetchLike = (input, init) => { - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${client.token}`); - return fetch(input, { ...init, headers }); - }; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "mattermost", + accountId: account.accountId, + log: (message) => logVerboseMessage(message), + }); const resolveMattermostMedia = async ( fileIds?: string[] | null, @@ -261,7 +243,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} try { const fetched = await core.channel.media.fetchRemoteMedia({ url: `${client.apiBaseUrl}/files/${fileId}`, - fetchImpl: fetchWithAuth, + requestInit: { + headers: { + Authorization: `Bearer ${client.token}`, + }, + }, filePathHint: fileId, maxBytes: mediaMaxBytes, }); @@ -375,22 +361,35 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); - const storeAllowFrom = normalizeAllowList( - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const normalizedGroupAllowFrom = normalizeMattermostAllowList( + account.config.groupAllowFrom ?? [], ); - const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); - const effectiveGroupAllowFrom = Array.from( - new Set([ - ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), - ...storeAllowFrom, - ]), + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), ); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizedAllowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", @@ -398,20 +397,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const isControlCommand = allowTextCommands && hasControlCommand; const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed({ + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom; + const senderAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, + allowNameMatching, }); - const groupAllowedForCommands = isSenderAllowed({ + const groupAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, allowFrom: effectiveGroupAllowFrom, + allowNameMatching, }); const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands, @@ -420,20 +422,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} allowTextCommands, hasControlCommand, }); - const commandAuthorized = - kind === "direct" - ? dmPolicy === "open" || senderAllowedForCommands - : commandGate.commandAuthorized; + const commandAuthorized = commandGate.commandAuthorized; - if (kind === "direct") { - if (dmPolicy === "disabled") { - logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); - return; - } - if (dmPolicy !== "open" && !senderAllowedForCommands) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "mattermost", + if (accessDecision.decision !== "allow") { + if (kind === "direct") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (accessDecision.decision === "pairing") { + const { code, created } = await pairing.upsertPairingRequest({ id: senderId, meta: { name: senderName }, }); @@ -454,26 +452,27 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); } } - } else { - logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); + return; } + logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); return; } - } else { - if (groupPolicy === "disabled") { + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) { logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); return; } - if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - logVerboseMessage("mattermost: drop group message (no group allowlist)"); - return; - } - if (!groupAllowedForCommands) { - logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); - return; - } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; } + if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) { + logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); + return; + } + logVerboseMessage( + `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`, + ); + return; } if (kind !== "direct" && commandGate.shouldBlock) { @@ -744,6 +743,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); @@ -780,21 +780,26 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onError: (err, info) => { runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, }); - await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: - typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected, + onSettled: () => { + markDispatchIdle(); }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, + }), }); - markDispatchIdle(); if (historyKey) { clearHistoryEntriesIfEnabled({ historyMap: channelHistories, @@ -859,68 +864,41 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const kind = channelKind(channelInfo.type); // Enforce DM/group policy and allowlist checks (same as normal messages) - if (kind === "direct") { - const dmPolicy = account.config.dmPolicy ?? "pairing"; - if (dmPolicy === "disabled") { - logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`); - return; - } - // For pairing/allowlist modes, only allow reactions from approved senders - if (dmPolicy !== "open") { - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const storeAllowFrom = normalizeAllowList( - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), - ); - const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); - const allowed = isSenderAllowed({ + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeMattermostAllowList( + await readStoreAllowFromForDmPolicy({ + provider: "mattermost", + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }), + ); + const reactionAccess = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []), + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ senderId: userId, senderName, - allowFrom: effectiveAllowFrom, - }); - if (!allowed) { - logVerboseMessage( - `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`, - ); - return; - } - } - } else if (kind) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; - if (groupPolicy === "disabled") { - logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); - return; - } - if (groupPolicy === "allowlist") { - const dmPolicyForStore = account.config.dmPolicy ?? "pairing"; - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); - const storeAllowFrom = normalizeAllowList( - dmPolicyForStore === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + allowFrom, + allowNameMatching, + }), + }); + if (reactionAccess.decision !== "allow") { + if (kind === "direct") { + logVerboseMessage( + `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`, ); - const effectiveGroupAllowFrom = Array.from( - new Set([ - ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), - ...storeAllowFrom, - ]), + } else { + logVerboseMessage( + `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`, ); - // Drop when allowlist is empty (same as normal message handler) - const allowed = - effectiveGroupAllowFrom.length > 0 && - isSenderAllowed({ - senderId: userId, - senderName, - allowFrom: effectiveGroupAllowFrom, - }); - if (!allowed) { - logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); - return; - } } + return; } const teamId = channelInfo?.team_id ?? undefined; diff --git a/extensions/mattermost/src/mattermost/probe.test.ts b/extensions/mattermost/src/mattermost/probe.test.ts new file mode 100644 index 000000000000..887ac576a854 --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { probeMattermost } from "./probe.js"; + +const mockFetch = vi.fn(); + +describe("probeMattermost", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns baseUrl missing for empty base URL", async () => { + await expect(probeMattermost(" ", "token")).resolves.toEqual({ + ok: false, + error: "baseUrl missing", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("normalizes base URL and returns bot info", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://mm.example.com/api/v4/users/me", + expect.objectContaining({ + headers: { Authorization: "Bearer bot-token" }, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + ok: true, + status: 200, + bot: { id: "bot-1", username: "clawbot" }, + }), + ); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("returns API error details from JSON response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "invalid auth token" }), { + status: 401, + statusText: "Unauthorized", + headers: { "content-type": "application/json" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 401, + error: "invalid auth token", + }), + ); + }); + + it("falls back to statusText when error body is empty", async () => { + mockFetch.mockResolvedValueOnce( + new Response("", { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/plain" }, + }), + ); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: 403, + error: "Forbidden", + }), + ); + }); + + it("returns fetch error when request throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( + expect.objectContaining({ + ok: false, + status: null, + error: "network down", + }), + ); + }); +}); diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index cb468ec14dba..eda98b21c0ea 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,5 +1,5 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk"; -import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js"; +import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; export type MattermostProbe = BaseProbeResult & { status?: number | null; @@ -7,18 +7,6 @@ export type MattermostProbe = BaseProbeResult & { bot?: MattermostUser; }; -async function readMattermostError(res: Response): Promise { - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - const data = (await res.json()) as { message?: string } | undefined; - if (data?.message) { - return data.message; - } - return JSON.stringify(data); - } - return await res.text(); -} - export async function probeMattermost( baseUrl: string, botToken: string, diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts index 9f90f1f2ab87..358d3f43f7f9 100644 --- a/extensions/mattermost/src/onboarding.ts +++ b/extensions/mattermost/src/onboarding.ts @@ -22,6 +22,25 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise { ); } +async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{ + botToken: string; + baseUrl: string; +}> { + const botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { botToken, baseUrl }; +} + export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { @@ -90,18 +109,9 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else if (accountConfigured) { const keep = await prompter.confirm({ @@ -109,32 +119,14 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keep) { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } } else { - botToken = String( - await prompter.text({ - message: "Enter Mattermost bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - baseUrl = String( - await prompter.text({ - message: "Enter Mattermost base URL", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); + const entered = await promptMattermostCredentials(prompter); + botToken = entered.botToken; + baseUrl = entered.baseUrl; } if (botToken || baseUrl) { diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 7501cca3f313..150989b7b442 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -7,6 +7,11 @@ export type MattermostAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** + * Break-glass override: allow mutable identity matching (@username/display name) in allowlists. + * Default behavior is ID-only matching. + */ + dangerouslyAllowNameMatching?: boolean; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Mattermost account. Default: true. */ diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index b577c8cfc901..ca80fd772783 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.1.26" }, diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index dfd9b2b8030b..da88bf069fe7 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,16 +1,13 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", "dependencies": { "@lancedb/lancedb": "^0.26.2", "@sinclair/typebox": "0.34.48", - "openai": "^6.22.0" - }, - "devDependencies": { - "openclaw": "workspace:*" + "openai": "^6.25.0" }, "openclaw": { "extensions": [ diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 3913b304c6bd..c5744e546c15 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 5859decd9efb..2402bf1a4fa7 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 3f44afa994d4..9cd947a3ba8e 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/msteams", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { "@microsoft/agents-hosting": "^1.3.1", "express": "^5.2.1" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index be7251979d10..167075d1c6e1 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,35 +1,110 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, + buildMSTeamsMediaPayload, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, +} from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; -const detectMimeMock = vi.fn(async () => "image/png"); +const GRAPH_HOST = "graph.microsoft.com"; +const SHAREPOINT_HOST = "contoso.sharepoint.com"; +const AZUREEDGE_HOST = "azureedge.net"; +const TEST_HOST = "x"; +const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`; +const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment); +const SAVED_PNG_PATH = "/tmp/saved.png"; +const SAVED_PDF_PATH = "/tmp/saved.pdf"; +const TEST_URL_IMAGE = createTestUrl("img"); +const TEST_URL_IMAGE_PNG = createTestUrl("img.png"); +const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png"); +const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg"); +const TEST_URL_PDF = createTestUrl("x.pdf"); +const TEST_URL_PDF_1 = createTestUrl("1.pdf"); +const TEST_URL_PDF_2 = createTestUrl("2.pdf"); +const TEST_URL_HTML_A = createTestUrl("a.png"); +const TEST_URL_HTML_B = createTestUrl("b.png"); +const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png"); +const TEST_URL_DOC_PDF = createTestUrl("doc.pdf"); +const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl"); +const TEST_URL_OUTSIDE_ALLOWLIST = "https://evil.test/img"; +const CONTENT_TYPE_IMAGE_PNG = "image/png"; +const CONTENT_TYPE_APPLICATION_PDF = "application/pdf"; +const CONTENT_TYPE_TEXT_HTML = "text/html"; +const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info"; +const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; +const MAX_REDIRECT_HOPS = 5; +type RemoteMediaFetchParams = { + url: string; + maxBytes?: number; + filePathHint?: string; + ssrfPolicy?: SsrFPolicy; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG); const saveMediaBufferMock = vi.fn(async () => ({ - path: "/tmp/saved.png", - contentType: "image/png", + path: SAVED_PNG_PATH, + contentType: CONTENT_TYPE_IMAGE_PNG, })); -const fetchRemoteMediaMock = vi.fn( - async (params: { - url: string; - maxBytes?: number; - filePathHint?: string; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - }) => { - const fetchFn = params.fetchImpl ?? fetch; - const res = await fetchFn(params.url); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); +const readRemoteMediaResponse = async ( + res: Response, + params: Pick, +) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { + throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); + } + return { + buffer, + contentType: res.headers.get("content-type") ?? undefined, + fileName: params.filePathHint, + }; +}; + +function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { + if (pattern.startsWith("*.")) { + const suffix = pattern.slice(2); + return suffix.length > 0 && hostname !== suffix && hostname.endsWith(`.${suffix}`); + } + return hostname === pattern; +} + +function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean { + if (!policy?.hostnameAllowlist || policy.hostnameAllowlist.length === 0) { + return true; + } + const hostname = new URL(url).hostname.toLowerCase(); + return policy.hostnameAllowlist.some((pattern) => + isHostnameAllowedByPattern(hostname, pattern.toLowerCase()), + ); +} + +const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { + const fetchFn = params.fetchImpl ?? fetch; + let currentUrl = params.url; + for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) { + if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) { + throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`); } - const buffer = Buffer.from(await res.arrayBuffer()); - if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { - throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); + const res = await fetchFn(currentUrl, { redirect: "manual" }); + if (REDIRECT_STATUS_CODES.includes(res.status)) { + const location = res.headers.get("location"); + if (!location) { + throw new Error("redirect missing location"); + } + currentUrl = new URL(location, currentUrl).toString(); + continue; } - return { - buffer, - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; - }, -); + return readRemoteMediaResponse(res, params); + } + throw new Error("too many redirects"); +}); const runtimeStub = { media: { @@ -45,11 +120,542 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; +type DownloadedMedia = Awaited>; +type MSTeamsMediaPayload = ReturnType; +type DownloadAttachmentsBuildOverrides = Partial< + Omit +> & + Pick; +type DownloadAttachmentsNoFetchOverrides = Partial< + Omit +> & + Pick; +type DownloadGraphMediaOverrides = Partial< + Omit +>; +type FetchFn = typeof fetch; +type MSTeamsAttachments = DownloadAttachmentsParams["attachments"]; +type AttachmentPlaceholderInput = Parameters[0]; +type GraphMessageUrlParams = Parameters[0]; +type LabeledCase = { label: string }; +type FetchCallExpectation = { expectFetchCalled?: boolean }; +type DownloadedMediaExpectation = { path?: string; placeholder?: string }; +type MSTeamsMediaPayloadExpectation = { + firstPath: string; + paths: string[]; + types: string[]; +}; + +const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`; +const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`; +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_ALLOW_HOSTS = [TEST_HOST]; +const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST]; +const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file"); +const MEDIA_PLACEHOLDER_IMAGE = ""; +const MEDIA_PLACEHOLDER_DOCUMENT = ""; +const formatImagePlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE; +const formatDocumentPlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT; +const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST_URL_IMAGE }; +const PNG_BUFFER = Buffer.from("png"); +const PNG_BASE64 = PNG_BUFFER.toString("base64"); +const PDF_BUFFER = Buffer.from("pdf"); +const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") }); +const asSingleItemArray = (value: T) => [value]; +const withLabel = (label: string, fields: T): T & LabeledCase => ({ + label, + ...fields, +}); +const buildAttachment = >(contentType: string, props: T) => ({ + contentType, + ...props, +}); +const createHtmlAttachment = (content: string) => + buildAttachment(CONTENT_TYPE_TEXT_HTML, { content }); +const buildHtmlImageTag = (src: string) => ``; +const createHtmlImageAttachments = (sources: string[], prefix = "") => + asSingleItemArray(createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`)); +const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) => + contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl })); +const createImageAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls); +const createPdfAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls); +const createTeamsFileDownloadInfoAttachments = ( + downloadUrl = TEST_URL_FILE_DOWNLOAD, + fileType = "png", +) => + asSingleItemArray( + buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, { + content: { downloadUrl, fileType }, + }), + ); +const createMediaEntriesWithType = (contentType: string, ...paths: string[]) => + paths.map((path) => ({ path, contentType })); +const createHostedContentsWithType = (contentType: string, ...ids: string[]) => + ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 })); +const createImageMediaEntries = (...paths: string[]) => + createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths); +const createHostedImageContents = (...ids: string[]) => + createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids); +const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => { + return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF); +}; +const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => { + const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + return new Response(new Uint8Array(raw), { + status, + headers: { "content-type": contentType }, + }); +}; +const createJsonResponse = (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { status }); +const createTextResponse = (body: string, status = 200) => new Response(body, { status }); +const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value }); +const createNotFoundResponse = () => new Response("not found", { status: 404 }); +const createRedirectResponse = (location: string, status = 302) => + new Response(null, { status, headers: { location } }); + +const createOkFetchMock = (contentType: string, payload = "png") => + vi.fn(async () => createBufferResponse(payload, contentType)); +const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn; + +const buildDownloadParams = ( + attachments: MSTeamsAttachments, + overrides: DownloadAttachmentsBuildOverrides = {}, +): DownloadAttachmentsParams => { + return { + attachments, + maxBytes: DEFAULT_MAX_BYTES, + allowHosts: DEFAULT_ALLOW_HOSTS, + ...overrides, }; +}; + +const downloadAttachmentsWithFetch = async ( + attachments: MSTeamsAttachments, + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: FetchCallExpectation = {}, +) => { + const media = await downloadMSTeamsAttachments( + buildDownloadParams(attachments, { + ...overrides, + fetchFn: asFetchFn(fetchFn), + }), + ); + expectMockCallState(fetchFn, options.expectFetchCalled ?? true); + return media; +}; + +const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) => + vi.fn(async (_url: string, opts?: RequestInit) => { + const headers = new Headers(opts?.headers); + const hasAuth = Boolean(headers.get("Authorization")); + if (!hasAuth) { + return createTextResponse(params.unauthBody, params.unauthStatus); + } + return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG); + }); +const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => { + if (shouldCall) { + expect(mockFn).toHaveBeenCalled(); + } else { + expect(mockFn).not.toHaveBeenCalled(); + } +}; + +const DEFAULT_CHANNEL_TEAM_ID = "team-id"; +const DEFAULT_CHANNEL_ID = "chan-id"; +const createChannelGraphMessageUrlParams = (params: { + messageId: string; + replyToId?: string; + conversationId?: string; +}) => ({ + conversationType: "channel" as const, + ...params, + channelData: { + team: { id: DEFAULT_CHANNEL_TEAM_ID }, + channel: { id: DEFAULT_CHANNEL_ID }, + }, +}); +const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) => + params.replyToId + ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}` + : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`; + +const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => { + expect(media).toHaveLength(expectedLength); +}; +const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => { + expectAttachmentMediaLength(media, 1); + expectFirstMedia(media, expected); +}; +const expectMediaBufferSaved = () => { + expect(saveMediaBufferMock).toHaveBeenCalled(); +}; +const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => { + const first = media[0]; + if (expected.path !== undefined) { + expect(first?.path).toBe(expected.path); + } + if (expected.placeholder !== undefined) { + expect(first?.placeholder).toBe(expected.placeholder); + } +}; +const expectMSTeamsMediaPayload = ( + payload: MSTeamsMediaPayload, + expected: MSTeamsMediaPayloadExpectation, +) => { + expect(payload.MediaPath).toBe(expected.firstPath); + expect(payload.MediaUrl).toBe(expected.firstPath); + expect(payload.MediaPaths).toEqual(expected.paths); + expect(payload.MediaUrls).toEqual(expected.paths); + expect(payload.MediaTypes).toEqual(expected.types); +}; +type AttachmentPlaceholderCase = LabeledCase & { + attachments: AttachmentPlaceholderInput; + expected: string; +}; +type CountedAttachmentPlaceholderCaseDef = LabeledCase & { + attachments: AttachmentPlaceholderCase["attachments"]; + count: number; + formatPlaceholder: (count: number) => string; +}; +type AttachmentDownloadSuccessCase = LabeledCase & { + attachments: MSTeamsAttachments; + buildFetchFn?: () => unknown; + beforeDownload?: () => void; + assert?: (media: DownloadedMedia) => void; +}; +type AttachmentAuthRetryScenario = { + attachmentUrl: string; + unauthStatus: number; + unauthBody: string; + overrides?: Omit; +}; +type AttachmentAuthRetryCase = LabeledCase & { + scenario: AttachmentAuthRetryScenario; + expectedMediaLength: number; + expectTokenFetch: boolean; +}; +type GraphUrlExpectationCase = LabeledCase & { + params: GraphMessageUrlParams; + expectedPath: string; +}; +type ChannelGraphUrlCaseParams = { + messageId: string; + replyToId?: string; + conversationId?: string; +}; +type GraphMediaDownloadResult = { + fetchMock: ReturnType; + media: Awaited>; +}; +type GraphMediaSuccessCase = LabeledCase & { + buildOptions: () => GraphFetchMockOptions; + expectedLength: number; + assert?: (params: GraphMediaDownloadResult) => void; +}; +const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [ + withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }), + withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }), +]; +const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [ + withLabel("returns image placeholder for one image attachment", { + attachments: createImageAttachments(TEST_URL_IMAGE_PNG), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns image placeholder with count for many image attachments", { + attachments: [ + ...createImageAttachments(TEST_URL_IMAGE_1_PNG), + { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG }, + ], + count: 2, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("treats Teams file.download.info image attachments as images", { + attachments: createTeamsFileDownloadInfoAttachments(), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns document placeholder for non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF), + count: 1, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("returns document placeholder with count for many non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2), + count: 2, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("counts one inline image in html attachments", { + attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "

hi

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

hi

', - }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '', - }, - ]), - ).toBe(" (2 images)"); - }); + it.each(ATTACHMENT_PLACEHOLDER_CASES)( + "$label", + ({ attachments, expected }) => { + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); + }, + ); }); describe("downloadMSTeamsAttachments", () => { - it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(fetchMock).toHaveBeenCalledWith("https://x/img", undefined); - expect(saveMediaBufferMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.png"); - }); - - it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(fetchMock).toHaveBeenCalledWith("https://x/dl", undefined); - expect(media).toHaveLength(1); - }); - - it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - }); - detectMimeMock.mockResolvedValueOnce("application/pdf"); - saveMediaBufferMock.mockResolvedValueOnce({ - path: "/tmp/saved.pdf", - contentType: "application/pdf", - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf", undefined); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.pdf"); - expect(media[0]?.placeholder).toBe(""); - }); - - it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: '', - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png", undefined); - }); + it.each(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)( + "$label", + runAttachmentDownloadSuccessCase, + ); it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: ``, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([ + ...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]), + ]), + ); - expect(media).toHaveLength(1); - expect(saveMediaBufferMock).toHaveBeenCalled(); + expectSingleMedia(media); + expectMediaBufferSaved(); }); - it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("unauthorized", { status: 401 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); + it.each(ATTACHMENT_AUTH_RETRY_CASES)( + "$label", + runAttachmentAuthRetryCase, + ); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - allowHosts: ["x"], - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + it("skips urls outside the allowlist", async () => { + const fetchMock = vi.fn(); + const media = await downloadAttachmentsWithFetch( + createImageAttachments(TEST_URL_OUTSIDE_ALLOWLIST), + fetchMock, + { + allowHosts: [GRAPH_HOST], + }, + { expectFetchCalled: false }, + ); - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalledTimes(2); + expectAttachmentMediaLength(media, 0); }); - it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); - const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("forbidden", { status: 403 }); + it("blocks redirects to non-https URLs", async () => { + const insecureUrl = "http://x/insecure.png"; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === TEST_URL_IMAGE) { + return createRedirectResponse(insecureUrl); + } + if (url === insecureUrl) { + return createBufferResponse("insecure", CONTENT_TYPE_IMAGE_PNG); } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); + return createNotFoundResponse(); }); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, - ], - maxBytes: 1024 * 1024, - tokenProvider, - allowHosts: ["azureedge.net"], - authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadAttachmentsWithFetch( + createImageAttachments(TEST_URL_IMAGE), + fetchMock, + { + allowHosts: [TEST_HOST], + }, + ); - expect(media).toHaveLength(0); + expectAttachmentMediaLength(media, 0); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); - }); - - it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("buildMSTeamsGraphMessageUrls", () => { - it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - conversationId: "19:thread@thread.tacv2", - messageId: "123", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); - }); - - it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - messageId: "reply-id", - replyToId: "root-id", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain( - "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", - ); - }); - - it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "groupChat", - conversationId: "19:chat@thread.v2", - messageId: "456", - }); - expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => { + const urls = buildMSTeamsGraphMessageUrls(params); + expect(urls[0]).toContain(expectedPath); }); }); describe("downloadMSTeamsGraphMedia", () => { - it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "1", - contentType: "image/png", - contentBytes: base64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - }); - - it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const hostedBase64 = Buffer.from("png").toString("base64"); - const shareUrl = "https://contoso.sharepoint.com/site/file"; - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "hosted-1", - contentType: "image/png", - contentBytes: hostedBase64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(2); - }); + it.each(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; - fetchRemoteMediaMock.mockImplementationOnce(async (params) => { - const fetchFn = params.fetchImpl ?? fetch; - let currentUrl = params.url; - for (let i = 0; i < 5; i += 1) { - const res = await fetchFn(currentUrl, { redirect: "manual" }); - if ([301, 302, 303, 307, 308].includes(res.status)) { - const location = res.headers.get("location"); - if (!location) { - throw new Error("redirect missing location"); - } - currentUrl = new URL(location, currentUrl).toString(); - continue; - } - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - return { - buffer: Buffer.from(await res.arrayBuffer()), - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; - } - throw new Error("too many redirects"); - }); - - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(null, { - status: 302, - headers: { location: escapedUrl }, - }); - } - if (url === escapedUrl) { - return new Response(Buffer.from("should-not-be-fetched"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const { fetchMock, media } = await downloadGraphMediaWithMockOptions( + { + ...buildDefaultShareReferenceGraphFetchOptions({ + onShareRequest: () => createRedirectResponse(escapedUrl), + onUnhandled: (url) => { + if (url === escapedUrl) { + return createPdfResponse("should-not-be-fetched"); + } + return undefined; + }, + }), + }, + { + allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, + }, + ); - expect(media.media).toHaveLength(0); + expectAttachmentMediaLength(media.media, 0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); - expect( - calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")), - ).toBe(true); + expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true); expect(calledUrls).not.toContain(escapedUrl); }); }); describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); - const payload = buildMSTeamsMediaPayload([ - { path: "/tmp/a.png", contentType: "image/png" }, - { path: "/tmp/b.png", contentType: "image/png" }, - ]); - expect(payload.MediaPath).toBe("/tmp/a.png"); - expect(payload.MediaUrl).toBe("/tmp/a.png"); - expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaTypes).toEqual(["image/png", "image/png"]); + const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png")); + expectMSTeamsMediaPayload(payload, { + firstPath: "/tmp/a.png", + paths: ["/tmp/a.png", "/tmp/b.png"], + types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG], + }); }); }); }); diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 4583a30dfe5a..f6f16ff803e1 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -1,3 +1,4 @@ +import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk"; import { getMSTeamsRuntime } from "../runtime.js"; import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js"; import { @@ -7,6 +8,7 @@ import { isRecord, isUrlAllowed, normalizeContentType, + resolveMediaSsrfPolicy, resolveRequestUrl, resolveAuthAllowedHosts, resolveAllowedHosts, @@ -89,81 +91,17 @@ async function fetchWithAuthFallback(params: { tokenProvider?: MSTeamsAccessTokenProvider; fetchFn?: typeof fetch; requestInit?: RequestInit; - allowHosts: string[]; authAllowHosts: string[]; }): Promise { - const fetchFn = params.fetchFn ?? fetch; - const firstAttempt = await fetchFn(params.url, params.requestInit); - if (firstAttempt.ok) { - return firstAttempt; - } - if (!params.tokenProvider) { - return firstAttempt; - } - if (firstAttempt.status !== 401 && firstAttempt.status !== 403) { - return firstAttempt; - } - if (!isUrlAllowed(params.url, params.authAllowHosts)) { - return firstAttempt; - } - - const scopes = scopeCandidatesForUrl(params.url); - for (const scope of scopes) { - try { - const token = await params.tokenProvider.getAccessToken(scope); - const authHeaders = new Headers(params.requestInit?.headers); - authHeaders.set("Authorization", `Bearer ${token}`); - const res = await fetchFn(params.url, { - ...params.requestInit, - headers: authHeaders, - redirect: "manual", - }); - if (res.ok) { - return res; - } - const redirectUrl = readRedirectUrl(params.url, res); - if (redirectUrl && isUrlAllowed(redirectUrl, params.allowHosts)) { - const redirectRes = await fetchFn(redirectUrl, params.requestInit); - if (redirectRes.ok) { - return redirectRes; - } - if ( - (redirectRes.status === 401 || redirectRes.status === 403) && - isUrlAllowed(redirectUrl, params.authAllowHosts) - ) { - const redirectAuthHeaders = new Headers(params.requestInit?.headers); - redirectAuthHeaders.set("Authorization", `Bearer ${token}`); - const redirectAuthRes = await fetchFn(redirectUrl, { - ...params.requestInit, - headers: redirectAuthHeaders, - redirect: "manual", - }); - if (redirectAuthRes.ok) { - return redirectAuthRes; - } - } - } - } catch { - // Try the next scope. - } - } - - return firstAttempt; -} - -function readRedirectUrl(baseUrl: string, res: Response): string | null { - if (![301, 302, 303, 307, 308].includes(res.status)) { - return null; - } - const location = res.headers.get("location"); - if (!location) { - return null; - } - try { - return new URL(location, baseUrl).toString(); - } catch { - return null; - } + return await fetchWithBearerAuthScopeFallback({ + url: params.url, + scopes: scopeCandidatesForUrl(params.url), + tokenProvider: params.tokenProvider, + fetchFn: params.fetchFn, + requestInit: params.requestInit, + requireHttps: true, + shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts), + }); } /** @@ -186,6 +124,7 @@ export async function downloadMSTeamsAttachments(params: { } const allowHosts = resolveAllowedHosts(params.allowHosts); const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts); + const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts); // Download ANY downloadable attachment (not just images) const downloadable = list.filter(isDownloadableAttachment); @@ -254,13 +193,13 @@ export async function downloadMSTeamsAttachments(params: { contentTypeHint: candidate.contentTypeHint, placeholder: candidate.placeholder, preserveFilenames: params.preserveFilenames, + ssrfPolicy, fetchImpl: (input, init) => fetchWithAuthFallback({ url: resolveRequestUrl(input), tokenProvider: params.tokenProvider, fetchFn: params.fetchFn, requestInit: init, - allowHosts, authAllowHosts, }), }); diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 5303246de3df..1097d0caeb10 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -1,3 +1,4 @@ +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk"; import { getMSTeamsRuntime } from "../runtime.js"; import { downloadMSTeamsAttachments } from "./download.js"; import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js"; @@ -7,6 +8,7 @@ import { isRecord, isUrlAllowed, normalizeContentType, + resolveMediaSsrfPolicy, resolveRequestUrl, resolveAllowedHosts, } from "./shared.js"; @@ -32,25 +34,6 @@ type GraphAttachment = { content?: unknown; }; -function isRedirectStatus(status: number): boolean { - return [301, 302, 303, 307, 308].includes(status); -} - -function readRedirectUrl(baseUrl: string, res: Response): string | null { - if (!isRedirectStatus(res.status)) { - return null; - } - const location = res.headers.get("location"); - if (!location) { - return null; - } - try { - return new URL(location, baseUrl).toString(); - } catch { - return null; - } -} - function readNestedString(value: unknown, keys: Array): string | undefined { let current: unknown = value; for (const key of keys) { @@ -137,20 +120,31 @@ async function fetchGraphCollection(params: { url: string; accessToken: string; fetchFn?: typeof fetch; + ssrfPolicy?: SsrFPolicy; }): Promise<{ status: number; items: T[] }> { const fetchFn = params.fetchFn ?? fetch; - const res = await fetchFn(params.url, { - headers: { Authorization: `Bearer ${params.accessToken}` }, + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + fetchImpl: fetchFn, + init: { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }, + policy: params.ssrfPolicy, + auditContext: "msteams.graph.collection", }); - const status = res.status; - if (!res.ok) { - return { status, items: [] }; - } try { - const data = (await res.json()) as { value?: T[] }; - return { status, items: Array.isArray(data.value) ? data.value : [] }; - } catch { - return { status, items: [] }; + const status = response.status; + if (!response.ok) { + return { status, items: [] }; + } + try { + const data = (await response.json()) as { value?: T[] }; + return { status, items: Array.isArray(data.value) ? data.value : [] }; + } catch { + return { status, items: [] }; + } + } finally { + await release(); } } @@ -182,11 +176,13 @@ async function downloadGraphHostedContent(params: { maxBytes: number; fetchFn?: typeof fetch; preserveFilenames?: boolean; + ssrfPolicy?: SsrFPolicy; }): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> { const hosted = await fetchGraphCollection({ url: `${params.messageUrl}/hostedContents`, accessToken: params.accessToken, fetchFn: params.fetchFn, + ssrfPolicy: params.ssrfPolicy, }); if (hosted.items.length === 0) { return { media: [], status: hosted.status, count: 0 }; @@ -246,6 +242,7 @@ export async function downloadMSTeamsGraphMedia(params: { return { media: [] }; } const allowHosts = resolveAllowedHosts(params.allowHosts); + const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts); const messageUrl = params.messageUrl; let accessToken: string; try { @@ -259,66 +256,67 @@ export async function downloadMSTeamsGraphMedia(params: { const sharePointMedia: MSTeamsInboundMedia[] = []; const downloadedReferenceUrls = new Set(); try { - const msgRes = await fetchFn(messageUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, + const { response: msgRes, release } = await fetchWithSsrFGuard({ + url: messageUrl, + fetchImpl: fetchFn, + init: { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + policy: ssrfPolicy, + auditContext: "msteams.graph.message", }); - if (msgRes.ok) { - const msgData = (await msgRes.json()) as { - body?: { content?: string; contentType?: string }; - attachments?: Array<{ - id?: string; - contentUrl?: string; - contentType?: string; - name?: string; - }>; - }; + try { + if (msgRes.ok) { + const msgData = (await msgRes.json()) as { + body?: { content?: string; contentType?: string }; + attachments?: Array<{ + id?: string; + contentUrl?: string; + contentType?: string; + name?: string; + }>; + }; - // Extract SharePoint file attachments (contentType: "reference") - // Download any file type, not just images - const spAttachments = (msgData.attachments ?? []).filter( - (a) => a.contentType === "reference" && a.contentUrl && a.name, - ); - for (const att of spAttachments) { - const name = att.name ?? "file"; + // Extract SharePoint file attachments (contentType: "reference") + // Download any file type, not just images + const spAttachments = (msgData.attachments ?? []).filter( + (a) => a.contentType === "reference" && a.contentUrl && a.name, + ); + for (const att of spAttachments) { + const name = att.name ?? "file"; - try { - // SharePoint URLs need to be accessed via Graph shares API - const shareUrl = att.contentUrl!; - if (!isUrlAllowed(shareUrl, allowHosts)) { - continue; - } - const encodedUrl = Buffer.from(shareUrl).toString("base64url"); - const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`; + try { + // SharePoint URLs need to be accessed via Graph shares API + const shareUrl = att.contentUrl!; + if (!isUrlAllowed(shareUrl, allowHosts)) { + continue; + } + const encodedUrl = Buffer.from(shareUrl).toString("base64url"); + const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`; - const media = await downloadAndStoreMSTeamsRemoteMedia({ - url: sharesUrl, - filePathHint: name, - maxBytes: params.maxBytes, - contentTypeHint: "application/octet-stream", - preserveFilenames: params.preserveFilenames, - fetchImpl: async (input, init) => { - const requestUrl = resolveRequestUrl(input); - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${accessToken}`); - const res = await fetchFn(requestUrl, { - ...init, - headers, - }); - const redirectUrl = readRedirectUrl(requestUrl, res); - if (redirectUrl && !isUrlAllowed(redirectUrl, allowHosts)) { - throw new Error( - `MSTeams media redirect target blocked by allowlist: ${redirectUrl}`, - ); - } - return res; - }, - }); - sharePointMedia.push(media); - downloadedReferenceUrls.add(shareUrl); - } catch { - // Ignore SharePoint download failures. + const media = await downloadAndStoreMSTeamsRemoteMedia({ + url: sharesUrl, + filePathHint: name, + maxBytes: params.maxBytes, + contentTypeHint: "application/octet-stream", + preserveFilenames: params.preserveFilenames, + ssrfPolicy, + fetchImpl: async (input, init) => { + const requestUrl = resolveRequestUrl(input); + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${accessToken}`); + return await fetchFn(requestUrl, { ...init, headers }); + }, + }); + sharePointMedia.push(media); + downloadedReferenceUrls.add(shareUrl); + } catch { + // Ignore SharePoint download failures. + } } } + } finally { + await release(); } } catch { // Ignore message fetch failures. @@ -330,12 +328,14 @@ export async function downloadMSTeamsGraphMedia(params: { maxBytes: params.maxBytes, fetchFn: params.fetchFn, preserveFilenames: params.preserveFilenames, + ssrfPolicy, }); const attachments = await fetchGraphCollection({ url: `${messageUrl}/attachments`, accessToken, fetchFn: params.fetchFn, + ssrfPolicy, }); const normalizedAttachments = attachments.items.map(normalizeGraphAttachment); diff --git a/extensions/msteams/src/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts index 3887f9ee9271..2049609d8940 100644 --- a/extensions/msteams/src/attachments/payload.ts +++ b/extensions/msteams/src/attachments/payload.ts @@ -1,3 +1,5 @@ +import { buildMediaPayload } from "openclaw/plugin-sdk"; + export function buildMSTeamsMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -8,15 +10,5 @@ export function buildMSTeamsMediaPayload( MediaUrls?: string[]; MediaTypes?: string[]; } { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType ?? ""); - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined, - }; + return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true }); } diff --git a/extensions/msteams/src/attachments/remote-media.ts b/extensions/msteams/src/attachments/remote-media.ts index 20842b2b5a0e..162a797b57f5 100644 --- a/extensions/msteams/src/attachments/remote-media.ts +++ b/extensions/msteams/src/attachments/remote-media.ts @@ -1,3 +1,4 @@ +import type { SsrFPolicy } from "openclaw/plugin-sdk"; import { getMSTeamsRuntime } from "../runtime.js"; import { inferPlaceholder } from "./shared.js"; import type { MSTeamsInboundMedia } from "./types.js"; @@ -9,6 +10,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: { filePathHint: string; maxBytes: number; fetchImpl?: FetchLike; + ssrfPolicy?: SsrFPolicy; contentTypeHint?: string; placeholder?: string; preserveFilenames?: boolean; @@ -18,6 +20,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: { fetchImpl: params.fetchImpl, filePathHint: params.filePathHint, maxBytes: params.maxBytes, + ssrfPolicy: params.ssrfPolicy, }); const mime = await getMSTeamsRuntime().media.detectMime({ buffer: fetched.buffer, diff --git a/extensions/msteams/src/attachments/shared.test.ts b/extensions/msteams/src/attachments/shared.test.ts new file mode 100644 index 000000000000..a5d0a4bef5a4 --- /dev/null +++ b/extensions/msteams/src/attachments/shared.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + isUrlAllowed, + resolveAllowedHosts, + resolveAuthAllowedHosts, + resolveMediaSsrfPolicy, +} from "./shared.js"; + +describe("msteams attachment allowlists", () => { + it("normalizes wildcard host lists", () => { + expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]); + expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]); + }); + + it("requires https and host suffix match", () => { + const allowHosts = resolveAllowedHosts(["sharepoint.com"]); + expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true); + expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false); + expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false); + }); + + it("builds shared SSRF policy from suffix allowlist", () => { + expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({ + hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"], + }); + expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined(); + }); +}); diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts index c3cb01294493..abb98791b325 100644 --- a/extensions/msteams/src/attachments/shared.ts +++ b/extensions/msteams/src/attachments/shared.ts @@ -1,3 +1,9 @@ +import { + buildHostnameAllowlistPolicyFromSuffixAllowlist, + isHttpsUrlAllowedByHostnameSuffixAllowlist, + normalizeHostnameSuffixAllowlist, +} from "openclaw/plugin-sdk"; +import type { SsrFPolicy } from "openclaw/plugin-sdk"; import type { MSTeamsAttachmentLike } from "./types.js"; type InlineImageCandidate = @@ -250,55 +256,18 @@ export function safeHostForUrl(url: string): string { } } -function normalizeAllowHost(value: string): string { - const trimmed = value.trim().toLowerCase(); - if (!trimmed) { - return ""; - } - if (trimmed === "*") { - return "*"; - } - return trimmed.replace(/^\*\.?/, ""); -} - export function resolveAllowedHosts(input?: string[]): string[] { - if (!Array.isArray(input) || input.length === 0) { - return DEFAULT_MEDIA_HOST_ALLOWLIST.slice(); - } - const normalized = input.map(normalizeAllowHost).filter(Boolean); - if (normalized.includes("*")) { - return ["*"]; - } - return normalized; + return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST); } export function resolveAuthAllowedHosts(input?: string[]): string[] { - if (!Array.isArray(input) || input.length === 0) { - return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice(); - } - const normalized = input.map(normalizeAllowHost).filter(Boolean); - if (normalized.includes("*")) { - return ["*"]; - } - return normalized; + return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST); } -function isHostAllowed(host: string, allowlist: string[]): boolean { - if (allowlist.includes("*")) { - return true; - } - const normalized = host.toLowerCase(); - return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`)); +export function isUrlAllowed(url: string, allowlist: string[]): boolean { + return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist); } -export function isUrlAllowed(url: string, allowlist: string[]): boolean { - try { - const parsed = new URL(url); - if (parsed.protocol !== "https:") { - return false; - } - return isHostAllowed(parsed.hostname, allowlist); - } catch { - return false; - } +export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined { + return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts); } diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d7e9b3088e8e..16c7ad0fb49e 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,6 +6,8 @@ import { DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, } from "openclaw/plugin-sdk"; import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; @@ -127,8 +129,12 @@ export const msteamsPlugin: ChannelPlugin = { }, security: { collectWarnings: ({ cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.msteams !== undefined, + groupPolicy: cfg.channels?.msteams?.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 8163cab49405..06b2485eb3be 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -1,11 +1,8 @@ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, type GraphChannel, type GraphGroup, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: { const token = await resolveGraphToken(params.cfg); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: limit }); return users .map((user) => { diff --git a/extensions/msteams/src/graph-users.test.ts b/extensions/msteams/src/graph-users.test.ts new file mode 100644 index 000000000000..8b5f2b52dd09 --- /dev/null +++ b/extensions/msteams/src/graph-users.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { searchGraphUsers } from "./graph-users.js"; +import { fetchGraphJson } from "./graph.js"; + +vi.mock("./graph.js", () => ({ + escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")), + fetchGraphJson: vi.fn(), +})); + +describe("searchGraphUsers", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("returns empty array for blank queries", async () => { + await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]); + expect(fetchGraphJson).not.toHaveBeenCalled(); + }); + + it("uses exact mail/upn filter lookup for email-like queries", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-1", displayName: "User One" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-2", + query: "alice.o'hara@example.com", + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-2", + path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName", + }); + expect(result).toEqual([{ id: "user-1", displayName: "User One" }]); + }); + + it("uses displayName search with eventual consistency and custom top", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "user-2", displayName: "Bob" }], + } as never); + + const result = await searchGraphUsers({ + token: "token-3", + query: "bob", + top: 25, + }); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-3", + path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25", + headers: { ConsistencyLevel: "eventual" }, + }); + expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]); + }); + + it("falls back to default top and empty value handling", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "token-4", + path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10", + headers: { ConsistencyLevel: "eventual" }, + }); + }); +}); diff --git a/extensions/msteams/src/graph-users.ts b/extensions/msteams/src/graph-users.ts new file mode 100644 index 000000000000..965e83296fff --- /dev/null +++ b/extensions/msteams/src/graph-users.ts @@ -0,0 +1,29 @@ +import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js"; + +export async function searchGraphUsers(params: { + token: string; + query: string; + top?: number; +}): Promise { + const query = params.query.trim(); + if (!query) { + return []; + } + + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token: params.token, path }); + return res.value ?? []; + } + + const top = typeof params.top === "number" && params.top > 0 ? params.top : 10; + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`; + const res = await fetchGraphJson>({ + token: params.token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + return res.value ?? []; +} diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 943e32ef474e..d2c210153617 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -1,6 +1,7 @@ import type { MSTeamsConfig } from "openclaw/plugin-sdk"; import { GRAPH_ROOT } from "./attachments/shared.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type GraphUser = { @@ -22,18 +23,6 @@ export type GraphChannel = { export type GraphResponse = { value?: T[] }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - export function normalizeQuery(value?: string | null): string { return value?.trim() ?? ""; } diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cbd562ae3adb..0f27cf2d382a 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -49,6 +49,28 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +const createNoopAdapter = (): MSTeamsAdapter => ({ + continueConversation: async () => {}, + process: async () => {}, +}); + +const createRecordedSendActivity = ( + sink: string[], + failFirstWithStatusCode?: number, +): ((activity: unknown) => Promise<{ id: string }>) => { + let attempts = 0; + return async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + sink.push(content); + attempts += 1; + if (failFirstWithStatusCode !== undefined && attempts === 1) { + throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode }); + } + return { id: `id:${content}` }; + }; +}; + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -70,12 +92,12 @@ describe("msteams messenger", () => { expect(messages).toEqual([]); }); - it("filters silent reply prefixes", () => { + it("does not filter non-exact silent reply prefixes", () => { const messages = renderReplyPayloadsToMessages( [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], { textChunkLimit: 4000, tableMode: "code" }, ); - expect(messages).toEqual([]); + expect(messages).toEqual([{ text: `${SILENT_REPLY_TOKEN} -- ignored` }]); }); it("splits media into separate messages by default", () => { @@ -117,17 +139,9 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - sent.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(sent), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -149,11 +163,7 @@ describe("msteams messenger", () => { continueConversation: async (_appId, reference, logic) => { seen.reference = reference; await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - seen.texts.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, + sendActivity: createRecordedSendActivity(seen.texts), }); }, process: async () => {}, @@ -192,10 +202,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -242,20 +249,9 @@ describe("msteams messenger", () => { const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("throttled"), { statusCode: 429 }); - } - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(attempts, 429), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -280,10 +276,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); await expect( sendMSTeamsMessages({ @@ -303,18 +296,7 @@ describe("msteams messenger", () => { const adapter: MSTeamsAdapter = { continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("server error"), { - statusCode: 503, - }); - } - return { id: `id:${text ?? ""}` }; - }, - }); + await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, }; diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 1ee0cae68e4c..d4de764ea60d 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: { } }; - if (params.replyStyle === "thread") { - const ctx = params.context; - if (!ctx) { - throw new Error("Missing context for replyStyle=thread"); - } + const sendMessagesInContext = async (ctx: SendContext): Promise => { const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { const response = await sendWithRetry( @@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: { messageIds.push(extractMessageId(response) ?? "unknown"); } return messageIds; + }; + + if (params.replyStyle === "thread") { + const ctx = params.context; + if (!ctx) { + throw new Error("Missing context for replyStyle=thread"); + } + return await sendMessagesInContext(ctx); } const baseRef = buildConversationReference(params.conversationRef); @@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: { const messageIds: string[] = []; await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { - for (const [idx, message] of messages.entries()) { - const response = await sendWithRetry( - async () => - await ctx.sendActivity( - await buildActivity( - message, - params.conversationRef, - params.tokenProvider, - params.sharePointSiteId, - params.mediaMaxBytes, - ), - ), - { messageIndex: idx, messageCount: messages.length }, - ); - messageIds.push(extractMessageId(response) ?? "unknown"); - } + messageIds.push(...(await sendMessagesInContext(ctx))); }); return messageIds; } diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts new file mode 100644 index 000000000000..1fc6714a451d --- /dev/null +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -0,0 +1,234 @@ +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MSTeamsConversationStore } from "./conversation-store.js"; +import type { MSTeamsAdapter } from "./messenger.js"; +import { + type MSTeamsActivityHandler, + type MSTeamsMessageHandlerDeps, + registerMSTeamsHandlers, +} from "./monitor-handler.js"; +import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js"; +import type { MSTeamsPollStore } from "./polls.js"; +import { setMSTeamsRuntime } from "./runtime.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; + +const fileConsentMockState = vi.hoisted(() => ({ + uploadToConsentUrl: vi.fn(), +})); + +vi.mock("./file-consent.js", async () => { + const actual = await vi.importActual("./file-consent.js"); + return { + ...actual, + uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl, + }; +}); + +const runtimeStub: PluginRuntime = { + logging: { + shouldLogVerbose: () => false, + }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: () => ({ + enqueue: async () => {}, + }), + }, + }, +} as unknown as PluginRuntime; + +function createDeps(): MSTeamsMessageHandlerDeps { + const adapter: MSTeamsAdapter = { + continueConversation: async () => {}, + process: async () => {}, + }; + const conversationStore: MSTeamsConversationStore = { + upsert: async () => {}, + get: async () => null, + list: async () => [], + remove: async () => false, + findByUserId: async () => null, + }; + const pollStore: MSTeamsPollStore = { + createPoll: async () => {}, + getPoll: async () => null, + recordVote: async () => null, + }; + return { + cfg: {} as OpenClawConfig, + runtime: { + error: vi.fn(), + } as unknown as RuntimeEnv, + appId: "test-app-id", + adapter, + tokenProvider: { + getAccessToken: async () => "token", + }, + textLimit: 4000, + mediaMaxBytes: 8 * 1024 * 1024, + conversationStore, + pollStore, + log: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +} + +function createActivityHandler(): MSTeamsActivityHandler { + let handler: MSTeamsActivityHandler; + handler = { + onMessage: () => handler, + onMembersAdded: () => handler, + run: async () => {}, + }; + return handler; +} + +function createInvokeContext(params: { + conversationId: string; + uploadId: string; + action: "accept" | "decline"; +}): { context: MSTeamsTurnContext; sendActivity: ReturnType } { + const sendActivity = vi.fn(async () => ({ id: "activity-id" })); + const uploadInfo = + params.action === "accept" + ? { + name: "secret.txt", + uploadUrl: "https://upload.example.com/put", + contentUrl: "https://content.example.com/file", + uniqueId: "unique-id", + fileType: "txt", + } + : undefined; + return { + context: { + activity: { + type: "invoke", + name: "fileConsent/invoke", + conversation: { id: params.conversationId }, + value: { + type: "fileUpload", + action: params.action, + uploadInfo, + context: { uploadId: params.uploadId }, + }, + }, + sendActivity, + sendActivities: async () => [], + } as unknown as MSTeamsTurnContext, + sendActivity, + }; +} + +describe("msteams file consent invoke authz", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + clearPendingUploads(); + fileConsentMockState.uploadToConsentUrl.mockReset(); + fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); + }); + + it("uploads when invoke conversation matches pending upload conversation", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:victim@thread.v2;messageid=abc123", + uploadId, + action: "accept", + }); + + await handler.run?.(context); + + // invokeResponse should be sent immediately + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + + // Wait for async upload to complete + await vi.waitFor(() => { + expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); + }); + + expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://upload.example.com/put", + }), + ); + expect(getPendingUpload(uploadId)).toBeUndefined(); + }); + + it("rejects cross-conversation accept invoke and keeps pending upload", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:attacker@thread.v2", + uploadId, + action: "accept", + }); + + await handler.run?.(context); + + // invokeResponse should be sent immediately + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + + // Wait for async handler to complete + await vi.waitFor(() => { + expect(sendActivity).toHaveBeenCalledWith( + "The file upload request has expired. Please try sending the file again.", + ); + }); + + expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); + expect(getPendingUpload(uploadId)).toBeDefined(); + }); + + it("ignores cross-conversation decline invoke and keeps pending upload", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:attacker@thread.v2", + uploadId, + action: "decline", + }); + + await handler.run?.(context); + + // invokeResponse should be sent immediately + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + + expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); + expect(getPendingUpload(uploadId)).toBeDefined(); + expect(sendActivity).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index d4b848fde5ae..27d3e06929fe 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import type { MSTeamsConversationStore } from "./conversation-store.js"; import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js"; +import { normalizeMSTeamsConversationId } from "./inbound.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; @@ -42,6 +43,8 @@ async function handleFileConsentInvoke( context: MSTeamsTurnContext, log: MSTeamsMonitorLogger, ): Promise { + const expiredUploadMessage = + "The file upload request has expired. Please try sending the file again."; const activity = context.activity; if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") { return false; @@ -57,9 +60,24 @@ async function handleFileConsentInvoke( typeof consentResponse.context?.uploadId === "string" ? consentResponse.context.uploadId : undefined; + const pendingFile = getPendingUpload(uploadId); + if (pendingFile) { + const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId); + const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); + if (!invokeConversationId || pendingConversationId !== invokeConversationId) { + log.info("file consent conversation mismatch", { + uploadId, + expectedConversationId: pendingConversationId, + receivedConversationId: invokeConversationId || undefined, + }); + if (consentResponse.action === "accept") { + await context.sendActivity(expiredUploadMessage); + } + return true; + } + } if (consentResponse.action === "accept" && consentResponse.uploadInfo) { - const pendingFile = getPendingUpload(uploadId); if (pendingFile) { log.debug?.("user accepted file consent, uploading", { uploadId, @@ -101,9 +119,7 @@ async function handleFileConsentInvoke( } } else { log.debug?.("pending file not found for consent", { uploadId }); - await context.sendActivity( - "The file upload request has expired. Please try sending the file again.", - ); + await context.sendActivity(expiredUploadMessage); } } else { // User declined @@ -127,12 +143,14 @@ export function registerMSTeamsHandlers( const ctx = context as MSTeamsTurnContext; // Handle file consent invokes before passing to normal flow if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") { - const handled = await handleFileConsentInvoke(ctx, deps.log); - if (handled) { - // Send invoke response for file consent - await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } }); - return; - } + // Send invoke response IMMEDIATELY to prevent Teams timeout + await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } }); + + // Handle file upload asynchronously (don't await) + handleFileConsentInvoke(ctx, deps.log).catch((err) => { + deps.log.debug?.("file consent handler error", { error: String(err) }); + }); + return; } return originalRun.call(handler, context); }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts new file mode 100644 index 000000000000..2be36f897327 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -0,0 +1,99 @@ +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; +import { setMSTeamsRuntime } from "../runtime.js"; +import { createMSTeamsMessageHandler } from "./message-handler.js"; + +describe("msteams monitor handler authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); + setMSTeamsRuntime({ + logging: { shouldLogVerbose: () => false }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: (params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + enqueue: async (entry: T) => { + await params.onFlush([entry]); + }, + }), + }, + pairing: { + readAllowFromStore, + upsertPairingRequest: vi.fn(async () => null), + }, + text: { + hasControlCommand: () => false, + }, + }, + } as unknown as PluginRuntime); + + const conversationStore = { + upsert: vi.fn(async () => undefined), + }; + + const deps: MSTeamsMessageHandlerDeps = { + cfg: { + channels: { + msteams: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + } as OpenClawConfig, + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + appId: "test-app", + adapter: {} as MSTeamsMessageHandlerDeps["adapter"], + tokenProvider: { + getAccessToken: vi.fn(async () => "token"), + }, + textLimit: 4000, + mediaMaxBytes: 1024 * 1024, + conversationStore: + conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"], + pollStore: { + recordVote: vi.fn(async () => null), + } as unknown as MSTeamsMessageHandlerDeps["pollStore"], + log: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as MSTeamsMessageHandlerDeps["log"], + }; + + const handler = createMSTeamsMessageHandler(deps); + await handler({ + activity: { + id: "msg-1", + type: "message", + text: "", + from: { + id: "attacker-id", + aadObjectId: "attacker-aad", + name: "Attacker", + }, + recipient: { + id: "bot-id", + name: "Bot", + }, + conversation: { + id: "19:group@thread.tacv2", + conversationType: "groupChat", + }, + channelData: {}, + attachments: [], + }, + sendActivity: vi.fn(async () => undefined), + } as unknown as Parameters[0]); + + expect(readAllowFromStore).toHaveBeenCalledWith({ + channel: "msteams", + accountId: "default", + }); + expect(conversationStore.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index ae1f203a016b..520a158321e8 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -1,12 +1,19 @@ import { + DEFAULT_ACCOUNT_ID, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, + createScopedPairingAccess, logInboundDrop, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, + resolveDefaultGroupPolicy, + isDangerousNameMatchingEnabled, + readStoreAllowFromForDmPolicy, resolveMentionGating, formatAllowlistMatchMeta, + resolveEffectiveAllowFromLists, + resolveDmGroupAccessWithLists, type HistoryEntry, } from "openclaw/plugin-sdk"; import { @@ -52,6 +59,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { log, } = deps; const core = getMSTeamsRuntime(); + const pairing = createScopedPairingAccess({ + core, + channel: "msteams", + accountId: DEFAULT_ACCOUNT_ID, + }); const logVerboseMessage = (message: string) => { if (core.logging.shouldLogVerbose()) { log.debug?.(message); @@ -125,69 +137,31 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const senderName = from.name ?? from.id; const senderId = from.aadObjectId ?? from.id; const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing"; - const storedAllowFrom = - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("msteams").catch(() => []); + const storedAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: "msteams", + accountId: pairing.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; // Check DM policy for direct messages. const dmAllowFrom = msteamsCfg?.allowFrom ?? []; - const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom]; - if (isDirectMessage && msteamsCfg) { - const allowFrom = dmAllowFrom; - - if (dmPolicy === "disabled") { - log.debug?.("dropping dm (dms disabled)"); - return; - } - - if (dmPolicy !== "open") { - const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; - const allowMatch = resolveMSTeamsAllowlistMatch({ - allowFrom: effectiveAllowFrom, - senderId, - senderName, - }); - - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const request = await core.channel.pairing.upsertPairingRequest({ - channel: "msteams", - id: senderId, - meta: { name: senderName }, - }); - if (request) { - log.info("msteams pairing request created", { - sender: senderId, - label: senderName, - }); - } - } - log.debug?.("dropping dm (not allowlisted)", { - sender: senderId, - label: senderName, - allowlistMatch: formatAllowlistMatchMeta(allowMatch), - }); - return; - } - } - } + const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v)); + const groupAllowFrom = msteamsCfg?.groupAllowFrom; + const resolvedAllowFromLists = resolveEffectiveAllowFromLists({ + allowFrom: configuredDmAllowFrom, + groupAllowFrom, + storeAllowFrom: storedAllowFrom, + dmPolicy, + }); - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") : "disabled"; - const groupAllowFrom = - !isDirectMessage && msteamsCfg - ? (msteamsCfg.groupAllowFrom ?? - (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : [])) - : []; - const effectiveGroupAllowFrom = - !isDirectMessage && msteamsCfg - ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom] - : []; + const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; const teamId = activity.channelData?.team?.id; const teamName = activity.channelData?.team?.name; const channelName = activity.channelData?.channel?.name; @@ -198,6 +172,60 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { conversationId, channelName, }); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : effectiveGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: !isDirectMessage, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configuredDmAllowFrom, + groupAllowFrom, + storeAllowFrom: storedAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMSTeamsAllowlistMatch({ + allowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }).allowed, + }); + const effectiveDmAllowFrom = access.effectiveAllowFrom; + + if (isDirectMessage && msteamsCfg && access.decision !== "allow") { + if (access.reason === "dmPolicy=disabled") { + log.debug?.("dropping dm (dms disabled)"); + return; + } + const allowMatch = resolveMSTeamsAllowlistMatch({ + allowFrom: effectiveDmAllowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }); + if (access.decision === "pairing") { + const request = await pairing.upsertPairingRequest({ + id: senderId, + meta: { name: senderName }, + }); + if (request) { + log.info("msteams pairing request created", { + sender: senderId, + label: senderName, + }); + } + } + log.debug?.("dropping dm (not allowlisted)", { + sender: senderId, + label: senderName, + allowlistMatch: formatAllowlistMatchMeta(allowMatch), + }); + return; + } if (!isDirectMessage && msteamsCfg) { if (groupPolicy === "disabled") { @@ -224,11 +252,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - if (effectiveGroupAllowFrom.length > 0) { + if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") { const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); if (!allowMatch.allowed) { log.debug?.("dropping group message (not in groupAllowFrom)", { @@ -242,23 +271,26 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom; const ownerAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const groupAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, @@ -525,14 +557,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { log.info("dispatching to agent", { sessionKey: route.sessionKey }); try { - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ dispatcher, - replyOptions, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }), }); - markDispatchIdle(); log.info("dispatch complete", { queuedFinal, counts }); if (!queuedFinal) { diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 90ee1f3cd241..3c7daa58b3f3 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -184,7 +184,7 @@ describe("msteams policy", () => { ).toBe(true); }); - it("allows allowlist when sender name matches", () => { + it("blocks sender-name allowlist matches by default", () => { expect( isMSTeamsGroupAllowed({ groupPolicy: "allowlist", @@ -192,6 +192,18 @@ describe("msteams policy", () => { senderId: "other", senderName: "User", }), + ).toBe(false); + }); + + it("allows sender-name allowlist matches when explicitly enabled", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user"], + senderId: "other", + senderName: "User", + allowNameMatching: true, + }), ).toBe(true); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 6bab808ce919..a3545c0594fb 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): MSTeamsAllowlistMatch { return resolveAllowlistMatchSimple(params); } @@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): boolean { const { groupPolicy } = params; if (groupPolicy === "disabled") { diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index b6732c658c41..8434fa504162 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -1,6 +1,7 @@ import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk"; import { formatUnknownError } from "./errors.js"; import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { readAccessToken } from "./token-response.js"; import { resolveMSTeamsCredentials } from "./token.js"; export type ProbeMSTeamsResult = BaseProbeResult & { @@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult & { }; }; -function readAccessToken(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - const token = - (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; - return typeof token === "string" ? token : null; - } - return null; -} - function decodeJwtPayload(token: string): Record | null { const parts = token.split("."); if (parts.length < 2) { diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 55389f2f6960..36d611c39dad 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -68,6 +68,7 @@ export function createMSTeamsReplyDispatcher(params: { core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + typingCallbacks, deliver: async (payload) => { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: params.cfg, @@ -121,7 +122,6 @@ export function createMSTeamsReplyDispatcher(params: { hint, }); }, - onReplyStart: typingCallbacks.onReplyStart, }); return { diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index d87bea302e95..1e66c4972df7 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,8 +1,5 @@ +import { searchGraphUsers } from "./graph-users.js"; import { - escapeOData, - fetchGraphJson, - type GraphResponse, - type GraphUser, listChannelsForTeam, listTeamsByName, normalizeQuery, @@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: { results.push({ input, resolved: true, id: query }); continue; } - let users: GraphUser[] = []; - if (query.includes("@")) { - const escaped = escapeOData(query); - const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; - const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; - const res = await fetchGraphJson>({ token, path }); - users = res.value ?? []; - } else { - const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`; - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - users = res.value ?? []; - } + const users = await searchGraphUsers({ token, query, top: 10 }); const match = users[0]; if (!match?.id) { results.push({ input, resolved: false }); diff --git a/extensions/msteams/src/token-response.test.ts b/extensions/msteams/src/token-response.test.ts new file mode 100644 index 000000000000..2deddfbc736d --- /dev/null +++ b/extensions/msteams/src/token-response.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readAccessToken } from "./token-response.js"; + +describe("readAccessToken", () => { + it("returns raw string token values", () => { + expect(readAccessToken("abc")).toBe("abc"); + }); + + it("returns accessToken from object value", () => { + expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token"); + }); + + it("returns token fallback from object value", () => { + expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token"); + }); + + it("returns null for unsupported values", () => { + expect(readAccessToken({ accessToken: 123 })).toBeNull(); + expect(readAccessToken({ token: false })).toBeNull(); + expect(readAccessToken(null)).toBeNull(); + expect(readAccessToken(undefined)).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/token-response.ts b/extensions/msteams/src/token-response.ts new file mode 100644 index 000000000000..b08804b1c45d --- /dev/null +++ b/extensions/msteams/src/token-response.ts @@ -0,0 +1,11 @@ +export function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 80a1f5fbd2fd..09aa3b9ed28b 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts new file mode 100644 index 000000000000..68f8490efb97 --- /dev/null +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -0,0 +1,115 @@ +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; + +const hoisted = vi.hoisted(() => ({ + monitorNextcloudTalkProvider: vi.fn(), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, + }; +}); + +import { nextcloudTalkPlugin } from "./channel.js"; + +function createStartAccountCtx(params: { + account: ResolvedNextcloudTalkAccount; + abortSignal: AbortSignal; +}): ChannelGatewayContext { + const snapshot: ChannelAccountSnapshot = { + accountId: params.account.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.account.accountId, + account: params.account, + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + abortSignal: params.abortSignal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: (next) => { + Object.assign(snapshot, next); + }, + }; +} + +function buildAccount(): ResolvedNextcloudTalkAccount { + return { + accountId: "default", + enabled: true, + baseUrl: "https://nextcloud.example.com", + secret: "secret", + secretSource: "config", + config: { + baseUrl: "https://nextcloud.example.com", + botSecret: "secret", + webhookPath: "/nextcloud-talk-webhook", + webhookPort: 8788, + }, + }; +} + +describe("nextcloudTalkPlugin gateway.startAccount", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort, then stops the monitor", async () => { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + const abort = new AbortController(); + + const task = nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountCtx({ + account: buildAccount(), + abortSignal: abort.signal, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + let settled = false; + void task.then(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled).toBe(false); + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).not.toHaveBeenCalled(); + + abort.abort(); + await task; + + expect(stop).toHaveBeenCalledOnce(); + }); + + it("stops immediately when startAccount receives an already-aborted signal", async () => { + const stop = vi.fn(); + hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop }); + const abort = new AbortController(); + abort.abort(); + + await nextcloudTalkPlugin.gateway!.startAccount!( + createStartAccountCtx({ + account: buildAccount(), + abortSignal: abort.signal, + }), + ); + + expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 7471d70dab06..e49f057878cd 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,11 +5,14 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, type ChannelSetupInput, } from "openclaw/plugin-sdk"; +import { waitForAbortSignal } from "../../../src/infra/abort-signal.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -128,8 +131,13 @@ export const nextcloudTalkPlugin: ChannelPlugin = }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } @@ -325,7 +333,9 @@ export const nextcloudTalkPlugin: ChannelPlugin = statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), }); - return { stop }; + // Keep webhook channels pending for the account lifecycle. + await waitForAbortSignal(ctx.abortSignal); + stop(); }, logoutAccount: async ({ accountId, cfg }) => { const nextCfg = { ...cfg } as OpenClawConfig; diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 73369b1eb2ef..b52522983c26 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -40,15 +41,7 @@ export const NextcloudTalkAccountSchemaBase = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts new file mode 100644 index 000000000000..6ceca861ad85 --- /dev/null +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -0,0 +1,84 @@ +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { setNextcloudTalkRuntime } from "./runtime.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; + +describe("nextcloud-talk inbound authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker"]); + const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); + + setNextcloudTalkRuntime({ + channel: { + pairing: { + readAllowFromStore, + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns: () => false, + }, + }, + } as unknown as PluginRuntime); + + const message: NextcloudTalkInboundMessage = { + messageId: "m-1", + roomToken: "room-1", + roomName: "Room 1", + senderId: "attacker", + senderName: "Attacker", + text: "hello", + mediaType: "text/plain", + timestamp: Date.now(), + isGroupChat: true, + }; + + const account: ResolvedNextcloudTalkAccount = { + accountId: "default", + enabled: true, + baseUrl: "", + secret: "", + secretSource: "none", + config: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }; + + const config: CoreConfig = { + channels: { + "nextcloud-talk": { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + }; + + await handleNextcloudTalkInbound({ + message, + account, + config, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv, + }); + + expect(readAllowFromStore).toHaveBeenCalledWith({ + channel: "nextcloud-talk", + accountId: "default", + }); + expect(buildMentionRegexes).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 642e010b06d4..69b983b68cde 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,7 +1,17 @@ import { + GROUP_POLICY_BLOCKED_LABEL, + createScopedPairingAccess, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, - resolveControlCommandGate, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithCommandGate, + resolveOutboundMediaUrls, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -22,32 +32,17 @@ import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./typ const CHANNEL_ID = "nextcloud-talk" as const; async function deliverNextcloudTalkReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; roomToken: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const text = payload.text ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - await sendMessageNextcloudTalk(roomToken, combined, { accountId, replyTo: payload.replyToId, @@ -64,6 +59,11 @@ export async function handleNextcloudTalkInbound(params: { }): Promise { const { message, account, config, runtime, statusSink } = params; const core = getNextcloudTalkRuntime(); + const pairing = createScopedPairingAccess({ + core, + channel: CHANNEL_ID, + accountId: account.accountId, + }); const rawBody = message.text?.trim() ?? ""; if (!rawBody) { @@ -84,19 +84,31 @@ export async function handleNextcloudTalkInbound(params: { statusSink?.({ lastInboundAt: message.timestamp }); const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = (config.channels as Record | undefined)?.defaults as - | { groupPolicy?: string } - | undefined; - const groupPolicy = (account.config.groupPolicy ?? - defaultGroupPolicy?.groupPolicy ?? - "allowlist") as GroupPolicy; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig); + const { groupPolicy, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: + ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? + undefined) !== undefined, + groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "nextcloud-talk", + accountId: account.accountId, + blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, + log: (message) => runtime.log?.(message), + }); const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); - const storeAllowFrom = - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []); + const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + provider: CHANNEL_ID, + accountId: account.accountId, + dmPolicy, + readStore: pairing.readStoreForDmPolicy, + }); const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom); const roomMatch = resolveNextcloudTalkRoomMatch({ @@ -115,11 +127,6 @@ export async function handleNextcloudTalkInbound(params: { } const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); - const baseGroupAllowFrom = - configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; - - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, @@ -127,25 +134,33 @@ export async function handleNextcloudTalkInbound(params: { }); const useAccessGroups = (config.commands as Record | undefined)?.useAccessGroups !== false; - const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ - allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, - senderId, - }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { - configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, - allowed: senderAllowedForCommands, - }, - ], - allowTextCommands, - hasControlCommand, + const access = resolveDmGroupAccessWithCommandGate({ + isGroup, + dmPolicy, + groupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: storeAllowList, + isSenderAllowed: (allowFrom) => + resolveNextcloudTalkAllowlistMatch({ + allowFrom, + senderId, + }).allowed, + command: { + useAccessGroups, + allowTextCommands, + hasControlCommand, + }, }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = access.commandAuthorized; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; if (isGroup) { + if (access.decision !== "allow") { + runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`); + return; + } const groupAllow = resolveNextcloudTalkGroupAllow({ groupPolicy, outerAllowFrom: effectiveGroupAllowFrom, @@ -157,48 +172,35 @@ export async function handleNextcloudTalkInbound(params: { return; } } else { - if (dmPolicy === "disabled") { - runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); - return; - } - if (dmPolicy !== "open") { - const dmAllowed = resolveNextcloudTalkAllowlistMatch({ - allowFrom: effectiveAllowFrom, - senderId, - }).allowed; - if (!dmAllowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: CHANNEL_ID, - id: senderId, - meta: { name: senderName || undefined }, - }); - if (created) { - try { - await sendMessageNextcloudTalk( - roomToken, - core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your Nextcloud user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.( - `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`, - ); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await pairing.upsertPairingRequest({ + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + try { + await sendMessageNextcloudTalk( + roomToken, + core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); } } - runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`); - return; } + runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`); + return; } } - if (isGroup && commandGate.shouldBlock) { + if (access.shouldBlockControlCommand) { logInboundDrop({ log: (message) => runtime.log?.(message), channel: CHANNEL_ID, @@ -304,25 +306,21 @@ export async function handleNextcloudTalkInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverNextcloudTalkReply({ + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverNextcloudTalkReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - roomToken, - accountId: account.accountId, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); }, diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts new file mode 100644 index 000000000000..6cc149dde47e --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; + +describe("createNextcloudTalkWebhookServer auth order", () => { + it("rejects missing signature headers before reading request body", async () => { + const readBody = vi.fn(async () => { + throw new Error("should not be called for missing signature headers"); + }); + const harness = await startWebhookServer({ + path: "/nextcloud-auth-order", + maxBodyBytes: 128, + readBody, + onMessage: vi.fn(), + }); + + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: "{}", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Missing signature headers" }); + expect(readBody).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts new file mode 100644 index 000000000000..aaf9a30a9c84 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; + +describe("createNextcloudTalkWebhookServer backend allowlist", () => { + it("rejects requests from unexpected backend origins", async () => { + const onMessage = vi.fn(async () => {}); + const harness = await startWebhookServer({ + path: "/nextcloud-backend-check", + isBackendAllowed: (backend) => backend === "https://nextcloud.expected", + onMessage, + }); + + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "hello", + content: "hello", + mediaType: "text/plain", + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = generateNextcloudTalkSignature({ + body, + secret: "nextcloud-secret", + }); + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.unexpected", + }, + body, + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Invalid backend" }); + expect(onMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts new file mode 100644 index 000000000000..387e7a8304fc --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; +import type { NextcloudTalkInboundMessage } from "./types.js"; + +function createSignedRequest(body: string): { random: string; signature: string } { + return generateNextcloudTalkSignature({ + body, + secret: "nextcloud-secret", + }); +} + +describe("createNextcloudTalkWebhookServer replay handling", () => { + it("acknowledges replayed requests and skips onMessage side effects", async () => { + const seen = new Set(); + const onMessage = vi.fn(async () => {}); + const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => { + if (seen.has(message.messageId)) { + return false; + } + seen.add(message.messageId); + return true; + }); + const harness = await startWebhookServer({ + path: "/nextcloud-replay", + shouldProcessMessage, + onMessage, + }); + + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "hello", + content: "hello", + mediaType: "text/plain", + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = createSignedRequest(body); + const headers = { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.example", + }; + + const first = await fetch(harness.webhookUrl, { + method: "POST", + headers, + body, + }); + const second = await fetch(harness.webhookUrl, { + method: "POST", + headers, + body, + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(shouldProcessMessage).toHaveBeenCalledTimes(2); + expect(onMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.test-harness.ts b/extensions/nextcloud-talk/src/monitor.test-harness.ts new file mode 100644 index 000000000000..f0daf42e8d5a --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.test-harness.ts @@ -0,0 +1,59 @@ +import { type AddressInfo } from "node:net"; +import { afterEach } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import type { NextcloudTalkWebhookServerOptions } from "./types.js"; + +export type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +export type StartWebhookServerParams = Omit< + NextcloudTalkWebhookServerOptions, + "port" | "host" | "path" | "secret" +> & { + path: string; + secret?: string; + host?: string; + port?: number; +}; + +export async function startWebhookServer( + params: StartWebhookServerParams, +): Promise { + const host = params.host ?? "127.0.0.1"; + const port = params.port ?? 0; + const secret = params.secret ?? "nextcloud-secret"; + const { server, start } = createNextcloudTalkWebhookServer({ + ...params, + port, + host, + secret, + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + + const harness: WebhookHarness = { + webhookUrl: `http://${host}:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; + cleanupFns.push(harness.stop); + return harness; +} diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index ca9214fa6007..2de886864b75 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,5 +1,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import os from "node:os"; import { + createLoggerBackedRuntime, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, @@ -7,11 +9,13 @@ import { } from "openclaw/plugin-sdk"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; import type { CoreConfig, NextcloudTalkInboundMessage, + NextcloudTalkWebhookHeaders, NextcloudTalkWebhookPayload, NextcloudTalkWebhookServerOptions, } from "./types.js"; @@ -22,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const HEALTH_PATH = "/healthz"; +const WEBHOOK_ERRORS = { + missingSignatureHeaders: "Missing signature headers", + invalidBackend: "Invalid backend", + invalidSignature: "Invalid signature", + invalidPayloadFormat: "Invalid payload format", + payloadTooLarge: "Payload too large", + internalServerError: "Internal server error", +} as const; function formatError(err: unknown): string { if (err instanceof Error) { @@ -30,6 +42,14 @@ function formatError(err: unknown): string { return typeof err === "string" ? err : JSON.stringify(err); } +function normalizeOrigin(value: string): string | null { + try { + return new URL(value).origin.toLowerCase(); + } catch { + return null; + } +} + function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { try { const data = JSON.parse(body); @@ -50,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { } } +function writeJsonResponse( + res: ServerResponse, + status: number, + body?: Record, +): void { + if (body) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + return; + } + res.writeHead(status); + res.end(); +} + +function writeWebhookError(res: ServerResponse, status: number, error: string): void { + if (res.headersSent) { + return; + } + writeJsonResponse(res, status, { error }); +} + +function validateWebhookHeaders(params: { + req: IncomingMessage; + res: ServerResponse; + isBackendAllowed?: (backend: string) => boolean; +}): NextcloudTalkWebhookHeaders | null { + const headers = extractNextcloudTalkHeaders( + params.req.headers as Record, + ); + if (!headers) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders); + return null; + } + if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend); + return null; + } + return headers; +} + +function verifyWebhookSignature(params: { + headers: NextcloudTalkWebhookHeaders; + body: string; + secret: string; + res: ServerResponse; +}): boolean { + const isValid = verifyNextcloudTalkSignature({ + signature: params.headers.signature, + random: params.headers.random, + body: params.body, + secret: params.secret, + }); + if (!isValid) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature); + return false; + } + return true; +} + +function decodeWebhookCreateMessage(params: { + body: string; + res: ServerResponse; +}): + | { kind: "message"; message: NextcloudTalkInboundMessage } + | { kind: "ignore" } + | { kind: "invalid" } { + const payload = parseWebhookPayload(params.body); + if (!payload) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat); + return { kind: "invalid" }; + } + if (payload.type !== "Create") { + return { kind: "ignore" }; + } + return { kind: "message", message: payloadToInboundMessage(payload) }; +} + function payloadToInboundMessage( payload: NextcloudTalkWebhookPayload, ): NextcloudTalkInboundMessage { @@ -91,6 +188,9 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe opts.maxBodyBytes > 0 ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES; + const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; + const isBackendAllowed = opts.isBackendAllowed; + const shouldProcessMessage = opts.shouldProcessMessage; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -106,47 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); - - const headers = extractNextcloudTalkHeaders( - req.headers as Record, - ); + const headers = validateWebhookHeaders({ + req, + res, + isBackendAllowed, + }); if (!headers) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing signature headers" })); return; } - const isValid = verifyNextcloudTalkSignature({ - signature: headers.signature, - random: headers.random, + const body = await readBody(req, maxBodyBytes); + + const hasValidSignature = verifyWebhookSignature({ + headers, body, secret, + res, }); - - if (!isValid) { - res.writeHead(401, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid signature" })); + if (!hasValidSignature) { return; } - const payload = parseWebhookPayload(body); - if (!payload) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid payload format" })); + const decoded = decodeWebhookCreateMessage({ + body, + res, + }); + if (decoded.kind === "invalid") { return; } - - if (payload.type !== "Create") { - res.writeHead(200); - res.end(); + if (decoded.kind === "ignore") { + writeJsonResponse(res, 200); return; } - const message = payloadToInboundMessage(payload); + const message = decoded.message; + if (shouldProcessMessage) { + const shouldProcess = await shouldProcessMessage(message); + if (!shouldProcess) { + writeJsonResponse(res, 200); + return; + } + } - res.writeHead(200); - res.end(); + writeJsonResponse(res, 200); try { await onMessage(message); @@ -155,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } } catch (err) { if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { - if (!res.headersSent) { - res.writeHead(413, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Payload too large" })); - } + writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge); return; } if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { - if (!res.headersSent) { - res.writeHead(408, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); - } + writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT")); return; } const error = err instanceof Error ? err : new Error(formatError(err)); onError?.(error); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } + writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError); } }); @@ -183,12 +276,25 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe }); }; + let stopped = false; const stop = () => { - server.close(); + if (stopped) { + return; + } + stopped = true; + try { + server.close(); + } catch { + // ignore close races while shutting down + } }; if (abortSignal) { - abortSignal.addEventListener("abort", stop, { once: true }); + if (abortSignal.aborted) { + stop(); + } else { + abortSignal.addEventListener("abort", stop, { once: true }); + } } return { server, start, stop }; @@ -212,13 +318,12 @@ export async function monitorNextcloudTalkProvider( cfg, accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.secret) { throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); @@ -232,12 +337,41 @@ export async function monitorNextcloudTalkProvider( channel: "nextcloud-talk", accountId: account.accountId, }); + const expectedBackendOrigin = normalizeOrigin(account.baseUrl); + const replayGuard = createNextcloudTalkReplayGuard({ + stateDir: core.state.resolveStateDir(process.env, os.homedir), + onDiskError: (error) => { + logger.warn( + `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`, + ); + }, + }); const { start, stop } = createNextcloudTalkWebhookServer({ port, host, path, secret: account.secret, + isBackendAllowed: (backend) => { + if (!expectedBackendOrigin) { + return true; + } + const backendOrigin = normalizeOrigin(backend); + return backendOrigin === expectedBackendOrigin; + }, + shouldProcessMessage: async (message) => { + const shouldProcess = await replayGuard.shouldProcessMessage({ + accountId: account.accountId, + roomToken: message.roomToken, + messageId: message.messageId, + }); + if (!shouldProcess) { + logger.warn( + `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`, + ); + } + return shouldProcess; + }, onMessage: async (message) => { core.channel.activity.record({ channel: "nextcloud-talk", @@ -263,7 +397,14 @@ export async function monitorNextcloudTalkProvider( abortSignal: opts.abortSignal, }); + if (opts.abortSignal?.aborted) { + return { stop }; + } await start(); + if (opts.abortSignal?.aborted) { + stop(); + return { stop }; + } const publicUrl = account.config.webhookPublicUrl ?? diff --git a/extensions/nextcloud-talk/src/replay-guard.test.ts b/extensions/nextcloud-talk/src/replay-guard.test.ts new file mode 100644 index 000000000000..0bf18acb600e --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-")); + tempDirs.push(dir); + return dir; +} + +describe("createNextcloudTalkReplayGuard", () => { + it("persists replay decisions across guard instances", async () => { + const stateDir = await makeTempDir(); + + const firstGuard = createNextcloudTalkReplayGuard({ stateDir }); + const firstAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + const replayAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + const secondGuard = createNextcloudTalkReplayGuard({ stateDir }); + const restartReplayAttempt = await secondGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + expect(firstAttempt).toBe(true); + expect(replayAttempt).toBe(false); + expect(restartReplayAttempt).toBe(false); + }); + + it("scopes replay state by account namespace", async () => { + const stateDir = await makeTempDir(); + const guard = createNextcloudTalkReplayGuard({ stateDir }); + + const accountAFirst = await guard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-9", + }); + const accountBFirst = await guard.shouldProcessMessage({ + accountId: "account-b", + roomToken: "room-1", + messageId: "msg-9", + }); + + expect(accountAFirst).toBe(true); + expect(accountBFirst).toBe(true); + }); +}); diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts new file mode 100644 index 000000000000..14b074ed2ab6 --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { createPersistentDedupe } from "openclaw/plugin-sdk"; + +const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MEMORY_MAX_SIZE = 1_000; +const DEFAULT_FILE_MAX_ENTRIES = 10_000; + +function sanitizeSegment(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function buildReplayKey(params: { roomToken: string; messageId: string }): string | null { + const roomToken = params.roomToken.trim(); + const messageId = params.messageId.trim(); + if (!roomToken || !messageId) { + return null; + } + return `${roomToken}:${messageId}`; +} + +export type NextcloudTalkReplayGuardOptions = { + stateDir: string; + ttlMs?: number; + memoryMaxSize?: number; + fileMaxEntries?: number; + onDiskError?: (error: unknown) => void; +}; + +export type NextcloudTalkReplayGuard = { + shouldProcessMessage: (params: { + accountId: string; + roomToken: string; + messageId: string; + }) => Promise; +}; + +export function createNextcloudTalkReplayGuard( + options: NextcloudTalkReplayGuardOptions, +): NextcloudTalkReplayGuard { + const stateDir = options.stateDir.trim(); + const persistentDedupe = createPersistentDedupe({ + ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS, + memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE, + fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES, + resolveFilePath: (namespace) => + path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`), + }); + + return { + shouldProcessMessage: async ({ accountId, roomToken, messageId }) => { + const replayKey = buildReplayKey({ roomToken, messageId }); + if (!replayKey) { + return true; + } + return await persistentDedupe.checkAndRecord(replayKey, { + namespace: accountId, + onDiskError: options.onDiskError, + }); + }, + }; +} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index ecdbe8437ae4..e7af64a965cc 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -169,6 +169,9 @@ export type NextcloudTalkWebhookServerOptions = { path: string; secret: string; maxBodyBytes?: number; + readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; + isBackendAllowed?: (backend: string) => boolean; + shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; onError?: (error: Error) => void; abortSignal?: AbortSignal; diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index b0b7d0c81d30..b99f48bd8df6 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 27ce113e3faa..2cff8f09ec93 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/nostr", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { "nostr-tools": "^2.23.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d0c1c30ac8b2..5e2d3c838d58 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -204,6 +204,23 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + async function expectPrivatePictureRejected(pictureUrl: string) { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: pictureUrl, + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + } + it("validates profile and publishes", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -263,37 +280,11 @@ describe("nostr-profile-http", () => { }); it("rejects private IP in picture URL (SSRF protection)", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://127.0.0.1/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg"); }); it("rejects non-https URLs", async () => { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 76bc26da1767..ae46e3fba4a7 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index bca4c655cd15..eb047ab7e73a 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/signal", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2d627eeb9a6f..9f3a96b6c415 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,6 @@ import { applyAccountNameToChannelSection, + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -17,6 +18,8 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSignalAccount, setAccountEnabledInConfigSection, signalOnboardingAdapter, @@ -123,8 +126,12 @@ export const signalPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } @@ -267,18 +274,8 @@ export const signalPlugin: ChannelPlugin = { return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8c936b45e36b..ca4558764b71 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/slack", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts new file mode 100644 index 000000000000..60e760c99506 --- /dev/null +++ b/extensions/slack/src/channel.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +const handleSlackActionMock = vi.fn(); + +vi.mock("./runtime.js", () => ({ + getSlackRuntime: () => ({ + channel: { + slack: { + handleSlackAction: handleSlackActionMock, + }, + }, + }), +})); + +import { slackPlugin } from "./channel.js"; + +describe("slackPlugin actions", () => { + it("forwards read threadId to Slack action handler", async () => { + handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false }); + const handleAction = slackPlugin.actions?.handleAction; + expect(handleAction).toBeDefined(); + + await handleAction!({ + action: "read", + channel: "slack", + accountId: "default", + cfg: {}, + params: { + channelId: "C123", + threadId: "1712345678.123456", + }, + }); + + expect(handleSlackActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "readMessages", + channelId: "C123", + threadId: "1712345678.123456", + }), + {}, + undefined, + ); + }); +}); + +describe("slackPlugin outbound", () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + }; + + it("uses threadId as threadTs fallback for sendText", async () => { + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" }); + const sendText = slackPlugin.outbound?.sendText; + expect(sendText).toBeDefined(); + + const result = await sendText!({ + cfg, + to: "C123", + text: "hello", + accountId: "default", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + + expect(sendSlack).toHaveBeenCalledWith( + "C123", + "hello", + expect.objectContaining({ + threadTs: "1712345678.123456", + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-text" }); + }); + + it("prefers replyToId over threadId for sendMedia", async () => { + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media" }); + const sendMedia = slackPlugin.outbound?.sendMedia; + expect(sendMedia).toBeDefined(); + + const result = await sendMedia!({ + cfg, + to: "C999", + text: "caption", + mediaUrl: "https://example.com/image.png", + accountId: "default", + replyToId: "1712000000.000001", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + + expect(sendSlack).toHaveBeenCalledWith( + "C999", + "caption", + expect.objectContaining({ + mediaUrl: "https://example.com/image.png", + threadTs: "1712000000.000001", + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-media" }); + }); +}); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 891dd6a590ca..003fd8957749 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,6 +19,8 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -150,8 +152,12 @@ export const slackPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.slack !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; @@ -177,7 +183,7 @@ export const slackPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), - allowExplicitReplyTagsWhenOff: true, + allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, messaging: { @@ -239,6 +245,7 @@ export const slackPlugin: ChannelPlugin = { await handleSlackMessageAction({ providerId: meta.id, ctx, + includeReadThreadId: true, invoke: async (action, cfg, toolContext) => await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), }), @@ -318,28 +325,30 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; + const threadTsValue = replyToId ?? threadId; const result = await send(to, text, { - threadTs: replyToId ?? undefined, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), }); return { channel: "slack", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => { const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; + const threadTsValue = replyToId ?? threadId; const result = await send(to, text, { mediaUrl, - threadTs: replyToId ?? undefined, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), }); diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index ef661765ffb0..0d6e34271236 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,11 +1,10 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.22", - "private": true, + "version": "2026.2.26", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" + "dependencies": { + "zod": "^4.3.6" }, "openclaw": { "extensions": [ diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts new file mode 100644 index 000000000000..6005cbd923b2 --- /dev/null +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RegisteredRoute = { + path: string; + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; +}; + +const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn()); +const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} }); + +vi.mock("openclaw/plugin-sdk", () => ({ + DEFAULT_ACCOUNT_ID: "default", + setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})), + registerPluginHttpRoute: registerPluginHttpRouteMock, + buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })), +})); + +vi.mock("./runtime.js", () => ({ + getSynologyRuntime: vi.fn(() => ({ + config: { loadConfig: vi.fn().mockResolvedValue({}) }, + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher, + }, + }, + })), +})); + +vi.mock("./client.js", () => ({ + sendMessage: vi.fn().mockResolvedValue(true), + sendFileUrl: vi.fn().mockResolvedValue(true), +})); + +const { createSynologyChatPlugin } = await import("./channel.js"); + +function makeReq(method: string, body: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.socket = { remoteAddress: "127.0.0.1" } as any; + process.nextTick(() => { + req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + return req; +} + +function makeRes(): ServerResponse & { _status: number; _body: string } { + const res = { + _status: 0, + _body: "", + writeHead(statusCode: number, _headers: Record) { + res._status = statusCode; + }, + end(body?: string) { + res._body = body ?? ""; + }, + } as any; + return res; +} + +function makeFormBody(fields: Record): string { + return Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); +} + +describe("Synology channel wiring integration", () => { + beforeEach(() => { + registerPluginHttpRouteMock.mockClear(); + dispatchReplyWithBufferedBlockDispatcher.mockClear(); + }); + + it("registers real webhook handler with resolved account config and enforces allowlist", async () => { + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + accounts: { + alerts: { + enabled: true, + token: "valid-token", + incomingUrl: "https://nas.example.com/incoming", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["456"], + }, + }, + }, + }, + }, + accountId: "alerts", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const started = await plugin.gateway.startAccount(ctx); + expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1); + + const firstCall = registerPluginHttpRouteMock.mock.calls[0]; + expect(firstCall).toBeTruthy(); + if (!firstCall) throw new Error("Expected registerPluginHttpRoute to be called"); + const registered = firstCall[0]; + expect(registered.path).toBe("/webhook/synology-alerts"); + expect(registered.accountId).toBe("alerts"); + expect(typeof registered.handler).toBe("function"); + + const req = makeReq( + "POST", + makeFormBody({ + token: "valid-token", + user_id: "123", + username: "unauthorized-user", + text: "Hello", + }), + ); + const res = makeRes(); + await registered.handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain("not authorized"); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + started.stop(); + }); +}); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 8c08b4f56f24..bc6c00a47126 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -39,6 +39,7 @@ vi.mock("zod", () => ({ })); const { createSynologyChatPlugin } = await import("./channel.js"); +const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk"); describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { @@ -57,6 +58,7 @@ describe("createSynologyChatPlugin", () => { const plugin = createSynologyChatPlugin(); expect(plugin.meta.id).toBe("synology-chat"); expect(plugin.meta.label).toBe("Synology Chat"); + expect(plugin.meta.docsPath).toBe("/channels/synology-chat"); }); }); @@ -181,6 +183,25 @@ describe("createSynologyChatPlugin", () => { expect(warnings.some((w: string) => w.includes("open"))).toBe(true); }); + it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => { + const plugin = createSynologyChatPlugin(); + const account = { + accountId: "default", + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + nasHost: "h", + webhookPath: "/w", + dmPolicy: "allowlist" as const, + allowedUserIds: [], + rateLimitPerMinute: 30, + botName: "Bot", + allowInsecureSsl: false, + }; + const warnings = plugin.security.collectWarnings({ account }); + expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); + }); + it("returns no warnings for fully configured account", () => { const plugin = createSynologyChatPlugin(); const account = { @@ -335,5 +356,68 @@ describe("createSynologyChatPlugin", () => { const result = await plugin.gateway.startAccount(ctx); expect(typeof result.stop).toBe("function"); }); + + it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => { + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockClear(); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + dmPolicy: "allowlist", + allowedUserIds: [], + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const result = await plugin.gateway.startAccount(ctx); + expect(typeof result.stop).toBe("function"); + expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds")); + expect(registerMock).not.toHaveBeenCalled(); + }); + + it("deregisters stale route before re-registering same account/path", async () => { + const unregisterFirst = vi.fn(); + const unregisterSecond = vi.fn(); + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123"], + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const first = await plugin.gateway.startAccount(ctx); + const second = await plugin.gateway.startAccount(ctx); + + expect(registerMock).toHaveBeenCalledTimes(2); + expect(unregisterFirst).toHaveBeenCalledTimes(1); + expect(unregisterSecond).not.toHaveBeenCalled(); + + // Clean up active route map so this module-level state doesn't leak across tests. + first.stop(); + second.stop(); + }); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 6dc953f5ddbf..431dfd2cbd2d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,6 +20,8 @@ import { createWebhookHandler } from "./webhook-handler.js"; const CHANNEL_ID = "synology-chat"; const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); +const activeRouteUnregisters = new Map void>(); + export function createSynologyChatPlugin() { return { id: CHANNEL_ID, @@ -29,7 +31,7 @@ export function createSynologyChatPlugin() { label: "Synology Chat", selectionLabel: "Synology Chat (Webhook)", detailLabel: "Synology Chat (Webhook)", - docsPath: "synology-chat", + docsPath: "/channels/synology-chat", blurb: "Connect your Synology NAS Chat to OpenClaw", order: 90, }, @@ -139,6 +141,11 @@ export function createSynologyChatPlugin() { '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', ); } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + warnings.push( + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + } return warnings; }, }, @@ -219,6 +226,12 @@ export function createSynologyChatPlugin() { ); return { stop: () => {} }; } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + log?.warn?.( + `Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`, + ); + return { stop: () => {} }; + } log?.info?.( `Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`, @@ -270,7 +283,16 @@ export function createSynologyChatPlugin() { log, }); - // Register HTTP route via the SDK + // Deregister any stale route from a previous start (e.g. on auto-restart) + // to avoid "already registered" collisions that trigger infinite loops. + const routeKey = `${accountId}:${account.webhookPath}`; + const prevUnregister = activeRouteUnregisters.get(routeKey); + if (prevUnregister) { + log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); + prevUnregister(); + activeRouteUnregisters.delete(routeKey); + } + const unregister = registerPluginHttpRoute({ path: account.webhookPath, pluginId: CHANNEL_ID, @@ -278,6 +300,7 @@ export function createSynologyChatPlugin() { log: (msg: string) => log?.info?.(msg), handler, }); + activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); @@ -285,6 +308,7 @@ export function createSynologyChatPlugin() { stop: () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); + activeRouteUnregisters.delete(routeKey); }, }; }, diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index b332f470689a..edb483069486 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock http and https modules before importing the client vi.mock("node:https", () => { @@ -15,33 +15,22 @@ vi.mock("node:http", () => { // Import after mocks are set up const { sendMessage, sendFileUrl } = await import("./client.js"); const https = await import("node:https"); +let fakeNowMs = 1_700_000_000_000; -function mockSuccessResponse() { - const httpsRequest = vi.mocked(https.request); - httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = 200; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from('{"success":true}')); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.write = vi.fn(); - req.end = vi.fn(); - req.destroy = vi.fn(); - return req; - }); +async function settleTimers(promise: Promise): Promise { + await Promise.resolve(); + await vi.runAllTimersAsync(); + return promise; } -function mockFailureResponse(statusCode = 500) { +function mockResponse(statusCode: number, body: string) { const httpsRequest = vi.mocked(https.request); httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; res.statusCode = statusCode; process.nextTick(() => { callback(res); - res.emit("data", Buffer.from("error")); + res.emit("data", Buffer.from(body)); res.emit("end"); }); const req = new EventEmitter() as any; @@ -52,26 +41,41 @@ function mockFailureResponse(statusCode = 500) { }); } +function mockSuccessResponse() { + mockResponse(200, '{"success":true}'); +} + +function mockFailureResponse(statusCode = 500) { + mockResponse(statusCode, "error"); +} + describe("sendMessage", () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + fakeNowMs += 10_000; + vi.setSystemTime(fakeNowMs); + }); + + afterEach(() => { + vi.useRealTimers(); }); it("returns true on successful send", async () => { mockSuccessResponse(); - const result = await sendMessage("https://nas.example.com/incoming", "Hello"); + const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello")); expect(result).toBe(true); }); it("returns false on server error after retries", async () => { mockFailureResponse(500); - const result = await sendMessage("https://nas.example.com/incoming", "Hello"); + const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello")); expect(result).toBe(false); }); it("includes user_ids when userId is numeric", async () => { mockSuccessResponse(); - await sendMessage("https://nas.example.com/incoming", "Hello", 42); + await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", 42)); const httpsRequest = vi.mocked(https.request); expect(httpsRequest).toHaveBeenCalled(); const callArgs = httpsRequest.mock.calls[0]; @@ -82,22 +86,27 @@ describe("sendMessage", () => { describe("sendFileUrl", () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + fakeNowMs += 10_000; + vi.setSystemTime(fakeNowMs); + }); + + afterEach(() => { + vi.useRealTimers(); }); it("returns true on success", async () => { mockSuccessResponse(); - const result = await sendFileUrl( - "https://nas.example.com/incoming", - "https://example.com/file.png", + const result = await settleTimers( + sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), ); expect(result).toBe(true); }); it("returns false on failure", async () => { mockFailureResponse(500); - const result = await sendFileUrl( - "https://nas.example.com/incoming", - "https://example.com/file.png", + const result = await settleTimers( + sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), ); expect(result).toBe(false); }); diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index 11330dcddc83..f77fd21ca8e0 100644 --- a/extensions/synology-chat/src/security.test.ts +++ b/extensions/synology-chat/src/security.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { + validateToken, + checkUserAllowed, + authorizeUserForDm, + sanitizeInput, + RateLimiter, +} from "./security.js"; describe("validateToken", () => { it("returns true for matching tokens", () => { @@ -24,8 +30,8 @@ describe("validateToken", () => { }); describe("checkUserAllowed", () => { - it("allows any user when allowlist is empty", () => { - expect(checkUserAllowed("user1", [])).toBe(true); + it("rejects all users when allowlist is empty", () => { + expect(checkUserAllowed("user1", [])).toBe(false); }); it("allows user in the allowlist", () => { @@ -37,6 +43,39 @@ describe("checkUserAllowed", () => { }); }); +describe("authorizeUserForDm", () => { + it("allows any user when dmPolicy is open", () => { + expect(authorizeUserForDm("user1", "open", [])).toEqual({ allowed: true }); + }); + + it("rejects all users when dmPolicy is disabled", () => { + expect(authorizeUserForDm("user1", "disabled", ["user1"])).toEqual({ + allowed: false, + reason: "disabled", + }); + }); + + it("rejects when dmPolicy is allowlist and list is empty", () => { + expect(authorizeUserForDm("user1", "allowlist", [])).toEqual({ + allowed: false, + reason: "allowlist-empty", + }); + }); + + it("rejects users not in allowlist", () => { + expect(authorizeUserForDm("user9", "allowlist", ["user1"])).toEqual({ + allowed: false, + reason: "not-allowlisted", + }); + }); + + it("allows users in allowlist", () => { + expect(authorizeUserForDm("user1", "allowlist", ["user1", "user2"])).toEqual({ + allowed: true, + }); + }); +}); + describe("sanitizeInput", () => { it("returns normal text unchanged", () => { expect(sanitizeInput("hello world")).toBe("hello world"); diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 43ff054b0779..22883babbf5a 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -4,6 +4,10 @@ import * as crypto from "node:crypto"; +export type DmAuthorizationResult = + | { allowed: true } + | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" }; + /** * Validate webhook token using constant-time comparison. * Prevents timing attacks that could leak token bytes. @@ -22,13 +26,37 @@ export function validateToken(received: string, expected: string): boolean { /** * Check if a user ID is in the allowed list. - * Empty allowlist = allow all users. + * Allowlist mode must be explicit; empty lists should not match any user. */ export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { - if (allowedUserIds.length === 0) return true; + if (allowedUserIds.length === 0) return false; return allowedUserIds.includes(userId); } +/** + * Resolve DM authorization for a sender across all DM policy modes. + * Keeps policy semantics in one place so webhook/startup behavior stays consistent. + */ +export function authorizeUserForDm( + userId: string, + dmPolicy: "open" | "allowlist" | "disabled", + allowedUserIds: string[], +): DmAuthorizationResult { + if (dmPolicy === "disabled") { + return { allowed: false, reason: "disabled" }; + } + if (dmPolicy === "open") { + return { allowed: true }; + } + if (allowedUserIds.length === 0) { + return { allowed: false, reason: "allowlist-empty" }; + } + if (!checkUserAllowed(userId, allowedUserIds)) { + return { allowed: false, reason: "not-allowlisted" }; + } + return { allowed: true }; +} + /** * Sanitize user input to prevent prompt injection attacks. * Filters known dangerous patterns and truncates long messages. diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 9248cc427e68..1c8ef393ced7 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -80,6 +80,24 @@ describe("createWebhookHandler", () => { }; }); + async function expectForbiddenByPolicy(params: { + account: Partial; + bodyContains: string; + }) { + const handler = createWebhookHandler({ + account: makeAccount(params.account), + deliver: vi.fn(), + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain(params.bodyContains); + } + it("rejects non-POST methods with 405", async () => { const handler = createWebhookHandler({ account: makeAccount(), @@ -129,12 +147,23 @@ describe("createWebhookHandler", () => { }); it("returns 403 for unauthorized user with allowlist policy", async () => { + await expectForbiddenByPolicy({ + account: { + dmPolicy: "allowlist", + allowedUserIds: ["456"], + }, + bodyContains: "not authorized", + }); + }); + + it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => { + const deliver = vi.fn(); const handler = createWebhookHandler({ account: makeAccount({ dmPolicy: "allowlist", - allowedUserIds: ["456"], + allowedUserIds: [], }), - deliver: vi.fn(), + deliver, log, }); @@ -143,22 +172,15 @@ describe("createWebhookHandler", () => { await handler(req, res); expect(res._status).toBe(403); - expect(res._body).toContain("not authorized"); + expect(res._body).toContain("Allowlist is empty"); + expect(deliver).not.toHaveBeenCalled(); }); it("returns 403 when DMs are disabled", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ dmPolicy: "disabled" }), - deliver: vi.fn(), - log, + await expectForbiddenByPolicy({ + account: { dmPolicy: "disabled" }, + bodyContains: "disabled", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("disabled"); }); it("returns 429 when rate limited", async () => { diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index d1dae50a6738..b077e61fc7c4 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -6,7 +6,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; import { sendMessage } from "./client.js"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; // One rate limiter per account, created lazily @@ -137,21 +137,25 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { return; } - // User allowlist check - if ( - account.dmPolicy === "allowlist" && - !checkUserAllowed(payload.user_id, account.allowedUserIds) - ) { + // DM policy authorization + const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds); + if (!auth.allowed) { + if (auth.reason === "disabled") { + respond(res, 403, { error: "DMs are disabled" }); + return; + } + if (auth.reason === "allowlist-empty") { + log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message"); + respond(res, 403, { + error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.", + }); + return; + } log?.warn(`Unauthorized user: ${payload.user_id}`); respond(res, 403, { error: "User not authorized" }); return; } - if (account.dmPolicy === "disabled") { - respond(res, 403, { error: "DMs are disabled" }); - return; - } - // Rate limit if (!rateLimiter.check(payload.user_id)) { log?.warn(`Rate limit exceeded for user: ${payload.user_id}`); diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index a89802860c79..4cf2a6276efe 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/telegram", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 60ceec6d98b4..0fd75ae7664d 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedTelegramAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig { } as OpenClawConfig; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { cfg: OpenClawConfig; accountId: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const account = telegramPlugin.config.resolveAccount( params.cfg, @@ -122,4 +112,74 @@ describe("telegramPlugin duplicate token guard", () => { expect(probeTelegram).not.toHaveBeenCalled(); expect(monitorTelegramProvider).not.toHaveBeenCalled(); }); + + it("passes webhookPort through to monitor startup options", async () => { + const monitorTelegramProvider = vi.fn(async () => undefined); + const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "opsbot" } })); + const runtime = { + channel: { + telegram: { + monitorTelegramProvider, + probeTelegram, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime; + setTelegramRuntime(runtime); + + const cfg = createCfg(); + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + webhookUrl: "https://example.test/telegram-webhook", + webhookSecret: "secret", + webhookPort: 9876, + }; + + await telegramPlugin.gateway!.startAccount!( + createStartAccountCtx({ + cfg, + accountId: "ops", + runtime: createRuntimeEnv(), + }), + ); + + expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect.objectContaining({ + useWebhook: true, + webhookPort: 9876, + }), + ); + }); + + it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { + const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" })); + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + + const result = await telegramPlugin.outbound!.sendMedia!({ + cfg: createCfg(), + to: "12345", + text: "hello", + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp/agent-root"], + accountId: "ops", + }); + + expect(sendMessageTelegram).toHaveBeenCalledWith( + "12345", + "hello", + expect.objectContaining({ + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp/agent-root"], + }), + ); + expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" }); + }); }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a26dd956a6a5..0028e993fc0c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -17,6 +18,8 @@ import { parseTelegramReplyToMessageId, parseTelegramThreadId, resolveDefaultTelegramAccountId, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, @@ -195,8 +198,12 @@ export const telegramPlugin: ChannelPlugin { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.telegram !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } @@ -326,13 +333,24 @@ export const telegramPlugin: ChannelPlugin { + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const messageThreadId = parseTelegramThreadId(threadId); const result = await send(to, text, { verbose: false, mediaUrl, + mediaLocalRoots, messageThreadId, replyToMessageId, accountId: accountId ?? undefined, @@ -357,17 +375,7 @@ export const telegramPlugin: ChannelPlugin ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getTelegramRuntime().channel.telegram.probeTelegram( account.token, @@ -486,6 +494,7 @@ export const telegramPlugin: ChannelPlugin { diff --git a/extensions/test-utils/runtime-env.ts b/extensions/test-utils/runtime-env.ts new file mode 100644 index 000000000000..747ad5f5f3af --- /dev/null +++ b/extensions/test-utils/runtime-env.ts @@ -0,0 +1,12 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +export function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c58a60564a4c..c0e93868085f 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/tlon", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { "@urbit/aura": "^3.0.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 3dbc091ef6f3..ea80212088d2 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -13,7 +13,7 @@ export const TlonAuthorizationSchema = z.object({ channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(), }); -export const TlonAccountSchema = z.object({ +const tlonCommonConfigFields = { name: z.string().optional(), enabled: z.boolean().optional(), ship: ShipSchema.optional(), @@ -25,20 +25,14 @@ export const TlonAccountSchema = z.object({ autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), responsePrefix: z.string().optional(), +} satisfies z.ZodRawShape; + +export const TlonAccountSchema = z.object({ + ...tlonCommonConfigFields, }); export const TlonConfigSchema = z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - ship: ShipSchema.optional(), - url: z.string().optional(), - code: z.string().optional(), - allowPrivateNetwork: z.boolean().optional(), - groupChannels: z.array(ChannelNestSchema).optional(), - dmAllowlist: z.array(ShipSchema).optional(), - autoDiscoverChannels: z.boolean().optional(), - showModelSignature: z.boolean().optional(), - responsePrefix: z.string().optional(), + ...tlonCommonConfigFields, authorization: TlonAuthorizationSchema.optional(), defaultAuthorizedShips: z.array(ShipSchema).optional(), accounts: z.record(z.string(), TlonAccountSchema).optional(), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e9d9750537ba..7d2e8dbd31f7 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,6 +1,5 @@ -import { format } from "node:util"; import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; @@ -88,18 +87,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); if (!account.enabled) { @@ -422,11 +414,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise boolean; has: (id?: string | null) => boolean; @@ -5,29 +7,14 @@ export type ProcessedMessageTracker = { }; export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker { - const seen = new Set(); - const order: string[] = []; + const dedupe = createDedupeCache({ ttlMs: 0, maxSize: limit }); const mark = (id?: string | null) => { const trimmed = id?.trim(); if (!trimmed) { return true; } - if (seen.has(trimmed)) { - return false; - } - seen.add(trimmed); - order.push(trimmed); - if (order.length > limit) { - const overflow = order.length - limit; - for (let i = 0; i < overflow; i += 1) { - const oldest = order.shift(); - if (oldest) { - seen.delete(oldest); - } - } - } - return true; + return !dedupe.check(trimmed); }; const has = (id?: string | null) => { @@ -35,12 +22,12 @@ export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTra if (!trimmed) { return false; } - return seen.has(trimmed); + return dedupe.peek(trimmed); }; return { mark, has, - size: () => seen.size, + size: () => dedupe.size(), }; } diff --git a/extensions/tlon/src/urbit/channel-client.ts b/extensions/tlon/src/urbit/channel-client.ts index fb8af656a6ff..499860075b3d 100644 --- a/extensions/tlon/src/urbit/channel-client.ts +++ b/extensions/tlon/src/urbit/channel-client.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; @@ -43,7 +44,7 @@ export class UrbitChannelClient { return; } - const channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + const channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`; this.channelId = channelId; try { diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index b75d43f775c5..df128e51b871 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; @@ -59,7 +60,7 @@ export class UrbitSSEClient { this.url = ctx.baseUrl; this.cookie = normalizeUrbitCookie(cookie); this.ship = ctx.ship; - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`; this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString(); this.onReconnect = options.onReconnect ?? null; this.autoReconnect = options.autoReconnect !== false; @@ -343,7 +344,7 @@ export class UrbitSSEClient { await new Promise((resolve) => setTimeout(resolve, delay)); try { - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`; this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString(); if (this.onReconnect) { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 238484b49d76..970f756d73e2 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 4ff4d4532d9d..720bf7af3d8f 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { @@ -9,9 +9,6 @@ "@twurple/chat": "^8.0.3", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index fc8fd184d1e0..83746717e4a3 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => { channel: "testchannel", }; + function runAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + return checkTwitchAccessControl({ + message: { + ...mockMessage, + ...params.message, + }, + account: { + ...mockAccount, + ...params.account, + }, + botUsername: "testbot", + }); + } + + function expectSingleRoleAllowed(params: { + role: NonNullable[number]; + message: Partial; + }) { + const result = runAccessCheck({ + account: { allowedRoles: [params.role] }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const message: TwitchChatMessage = { @@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => { describe("allowedRoles", () => { it("allows users with matching role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); expect(result.matchSource).toBe("role"); }); @@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => { }); it("handles moderator role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); }); it("handles subscriber role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["subscriber"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isSub: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "subscriber", + message: { isSub: true }, }); - expect(result.allowed).toBe(true); }); it("handles owner role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "owner", + message: { isOwner: true }, }); - expect(result.allowed).toBe(true); }); it("handles vip role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["vip"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isVip: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "vip", + message: { isVip: true }, }); - expect(result.allowed).toBe(true); }); }); @@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => { }); it("checks allowlist before allowedRoles", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456"], - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { + allowFrom: ["123456"], + allowedRoles: ["owner"], + }, + message: { + message: "@testbot hello", + isOwner: false, + }, }); expect(result.allowed).toBe(true); expect(result.matchSource).toBe("allowlist"); diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index cb2667cb195f..4cda51330b11 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + /** * Twitch-specific utility functions */ @@ -40,7 +42,7 @@ export function missingTargetError(provider: string, hint?: string): Error { * @returns A unique message ID */ export function generateMessageId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + return `${Date.now()}-${randomUUID()}`; } /** diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 0b7c63a3e431..41f8685d304e 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index 88328b6a3391..9acc9aec9873 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -76,6 +76,10 @@ Put under `plugins.entries.voice-call.config`: streaming: { enabled: true, streamPath: "/voice/stream", + preStartTimeoutMs: 5000, + maxPendingConnections: 32, + maxPendingConnectionsPerIp: 4, + maxConnections: 128, }, } ``` @@ -87,6 +91,13 @@ Notes: - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true. - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. +Streaming security defaults: + +- `streaming.preStartTimeoutMs` closes sockets that never send a valid `start` frame. +- `streaming.maxPendingConnections` caps total unauthenticated pre-start sockets. +- `streaming.maxPendingConnectionsPerIp` caps unauthenticated pre-start sockets per source IP. +- `streaming.maxConnections` caps total open media stream sockets (pending + active). + ## Stale call reaper Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook @@ -164,5 +175,7 @@ Actions: ## Notes - Uses webhook signature verification for Twilio/Telnyx/Plivo. +- Adds replay protection for Twilio and Plivo webhooks (valid duplicate callbacks are ignored safely). +- Twilio speech turns include a per-turn token so stale/replayed callbacks cannot complete a newer turn. - `responseModel` / `responseSystemPrompt` control AI auto-responses. - Media streaming requires `ws` and OpenAI Realtime API key. diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7d8607ea3679..374c658631f1 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "ws": "^8.19.0", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index eaf4e3fc0a53..83b681530217 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -81,6 +81,27 @@ function summarizeSeries(values: number[]): { }; } +function resolveCallMode(mode?: string): "notify" | "conversation" | undefined { + return mode === "notify" || mode === "conversation" ? mode : undefined; +} + +async function initiateCallAndPrintId(params: { + runtime: VoiceCallRuntime; + to: string; + message?: string; + mode?: string; +}) { + const result = await params.runtime.manager.initiateCall(params.to, undefined, { + message: params.message, + mode: resolveCallMode(params.mode), + }); + if (!result.success) { + throw new Error(result.error || "initiate failed"); + } + // eslint-disable-next-line no-console + console.log(JSON.stringify({ callId: result.callId }, null, 2)); +} + export function registerVoiceCallCli(params: { program: Command; config: VoiceCallConfig; @@ -112,16 +133,12 @@ export function registerVoiceCallCli(params: { if (!to) { throw new Error("Missing --to and no toNumber configured"); } - const result = await rt.manager.initiateCall(to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root @@ -136,16 +153,12 @@ export function registerVoiceCallCli(params: { ) .action(async (options: { to: string; message?: string; mode?: string }) => { const rt = await ensureRuntime(); - const result = await rt.manager.initiateCall(options.to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to: options.to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index 893e7868d471..ba1889edb4f1 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -30,6 +30,10 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi silenceDurationMs: 800, vadThreshold: 0.5, streamPath: "/voice/stream", + preStartTimeoutMs: 5000, + maxPendingConnections: 32, + maxPendingConnectionsPerIp: 4, + maxConnections: 128, }, skipSignatureVerification: false, stt: { provider: "openai", model: "whisper-1" }, diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 68b197c09bbd..36b77778e9f6 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -219,6 +219,17 @@ export const VoiceCallStreamingConfigSchema = z vadThreshold: z.number().min(0).max(1).default(0.5), /** WebSocket path for media stream connections */ streamPath: z.string().min(1).default("/voice/stream"), + /** + * Close unauthenticated media stream sockets if no valid `start` frame arrives in time. + * Protects against pre-auth idle connection hold attacks. + */ + preStartTimeoutMs: z.number().int().positive().default(5000), + /** Maximum number of concurrently pending (pre-start) media stream sockets. */ + maxPendingConnections: z.number().int().positive().default(32), + /** Maximum pending media stream sockets per source IP. */ + maxPendingConnectionsPerIp: z.number().int().positive().default(4), + /** Hard cap for all open media stream sockets (pending + active). */ + maxConnections: z.number().int().positive().default(128), }) .strict() .default({ @@ -228,6 +239,10 @@ export const VoiceCallStreamingConfigSchema = z silenceDurationMs: 800, vadThreshold: 0.5, streamPath: "/voice/stream", + preStartTimeoutMs: 5000, + maxPendingConnections: 32, + maxPendingConnectionsPerIp: 4, + maxConnections: 128, }); export type VoiceCallStreamingConfig = z.infer; diff --git a/extensions/voice-call/src/http-headers.test.ts b/extensions/voice-call/src/http-headers.test.ts new file mode 100644 index 000000000000..5141d1d27596 --- /dev/null +++ b/extensions/voice-call/src/http-headers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { getHeader } from "./http-headers.js"; + +describe("getHeader", () => { + it("returns first value when header is an array", () => { + expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first"); + }); + + it("matches headers case-insensitively", () => { + expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1"); + }); + + it("returns undefined for missing header", () => { + expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined(); + }); +}); diff --git a/extensions/voice-call/src/http-headers.ts b/extensions/voice-call/src/http-headers.ts new file mode 100644 index 000000000000..1e50658b6bb4 --- /dev/null +++ b/extensions/voice-call/src/http-headers.ts @@ -0,0 +1,12 @@ +export type HttpHeaderMap = Record; + +export function getHeader(headers: HttpHeaderMap, name: string): string | undefined { + const target = name.toLowerCase(); + const direct = headers[target]; + const value = + direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index 3d02cb323bea..06bb380c9163 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -17,12 +17,16 @@ import type { } from "./types.js"; class FakeProvider implements VoiceCallProvider { - readonly name = "plivo" as const; + readonly name: "plivo" | "twilio"; readonly playTtsCalls: PlayTtsInput[] = []; readonly hangupCalls: HangupCallInput[] = []; readonly startListeningCalls: StartListeningInput[] = []; readonly stopListeningCalls: StopListeningInput[] = []; + constructor(name: "plivo" | "twilio" = "plivo") { + this.name = name; + } + verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult { return { ok: true }; } @@ -46,17 +50,44 @@ class FakeProvider implements VoiceCallProvider { } } +let storeSeq = 0; + +function createTestStorePath(): string { + storeSeq += 1; + return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`); +} + +function createManagerHarness( + configOverrides: Record = {}, + provider = new FakeProvider(), +): { + manager: CallManager; + provider: FakeProvider; +} { + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + ...configOverrides, + }); + const manager = new CallManager(config, createTestStorePath()); + manager.initialize(provider, "https://example.com/voice/webhook"); + return { manager, provider }; +} + +function markCallAnswered(manager: CallManager, callId: string, eventId: string): void { + manager.processEvent({ + id: eventId, + type: "call.answered", + callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + }); +} + describe("CallManager", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); + const { manager } = createManagerHarness(); const { callId, success, error } = await manager.initiateCall("+15550000001"); expect(success).toBe(true); @@ -81,16 +112,7 @@ describe("CallManager", () => { }); it("speaks initial message on answered for notify mode (non-Twilio)", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); + const { manager, provider } = createManagerHarness(); const { callId, success } = await manager.initiateCall("+15550000002", undefined, { message: "Hello there", @@ -113,19 +135,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with missing caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-missing", type: "call.initiated", @@ -142,19 +156,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-anon", type: "call.initiated", @@ -172,19 +178,11 @@ describe("CallManager", () => { }); it("rejects inbound calls that only match allowlist suffixes", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-suffix", type: "call.initiated", @@ -202,18 +200,10 @@ describe("CallManager", () => { }); it("rejects duplicate inbound events with a single hangup call", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "disabled", }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-reject-init", type: "call.initiated", @@ -242,18 +232,11 @@ describe("CallManager", () => { }); it("accepts inbound calls that exactly match the allowlist", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-exact", type: "call.initiated", @@ -269,28 +252,14 @@ describe("CallManager", () => { }); it("completes a closed-loop turn without live audio", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000003"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-closed-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-closed-loop-answered"); const turnPromise = manager.continueCall(started.callId, "How can I help?"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -323,28 +292,14 @@ describe("CallManager", () => { }); it("rejects overlapping continueCall requests for the same call", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000004"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-overlap-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-overlap-answered"); const first = manager.continueCall(started.callId, "First prompt"); const second = await manager.continueCall(started.callId, "Second prompt"); @@ -368,30 +323,71 @@ describe("CallManager", () => { expect(provider.stopListeningCalls).toHaveLength(1); }); - it("tracks latency metadata across multiple closed-loop turns", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - transcriptTimeoutMs: 5000, - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); + it("ignores speech events with mismatched turnToken while waiting for transcript", async () => { + const { manager, provider } = createManagerHarness( + { + transcriptTimeoutMs: 5000, + }, + new FakeProvider("twilio"), + ); - const started = await manager.initiateCall("+15550000005"); + const started = await manager.initiateCall("+15550000004"); expect(started.success).toBe(true); + markCallAnswered(manager, started.callId, "evt-turn-token-answered"); + + const turnPromise = manager.continueCall(started.callId, "Prompt"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedTurnToken = provider.startListeningCalls[0]?.turnToken; + expect(typeof expectedTurnToken).toBe("string"); + manager.processEvent({ - id: "evt-multi-answered", - type: "call.answered", + id: "evt-turn-token-bad", + type: "call.speech", callId: started.callId, providerCallId: "request-uuid", timestamp: Date.now(), + transcript: "stale replay", + isFinal: true, + turnToken: "wrong-token", + }); + + const pendingState = await Promise.race([ + turnPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(pendingState).toBe("pending"); + + manager.processEvent({ + id: "evt-turn-token-good", + type: "call.speech", + callId: started.callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + transcript: "final answer", + isFinal: true, + turnToken: expectedTurnToken, }); + const turnResult = await turnPromise; + expect(turnResult.success).toBe(true); + expect(turnResult.transcript).toBe("final answer"); + + const call = manager.getCall(started.callId); + expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]); + }); + + it("tracks latency metadata across multiple closed-loop turns", async () => { + const { manager, provider } = createManagerHarness({ + transcriptTimeoutMs: 5000, + }); + + const started = await manager.initiateCall("+15550000005"); + expect(started.success).toBe(true); + + markCallAnswered(manager, started.callId, "evt-multi-answered"); + const firstTurn = manager.continueCall(started.callId, "First question"); await new Promise((resolve) => setTimeout(resolve, 0)); manager.processEvent({ @@ -436,28 +432,14 @@ describe("CallManager", () => { }); it("handles repeated closed-loop turns without waiter churn", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000006"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-loop-answered"); for (let i = 1; i <= 5; i++) { const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`); diff --git a/extensions/voice-call/src/manager/context.ts b/extensions/voice-call/src/manager/context.ts index 1af703ed3276..ed14a167e120 100644 --- a/extensions/voice-call/src/manager/context.ts +++ b/extensions/voice-call/src/manager/context.ts @@ -6,6 +6,7 @@ export type TranscriptWaiter = { resolve: (text: string) => void; reject: (err: Error) => void; timeout: NodeJS.Timeout; + turnToken?: string; }; export type CallManagerRuntimeState = { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 74d1f10e46c2..ec2a26cd051c 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -45,34 +45,57 @@ function createProvider(overrides: Partial = {}): VoiceCallPr }; } +function createInboundDisabledConfig() { + return VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + inboundPolicy: "disabled", + }); +} + +function createInboundInitiatedEvent(params: { + id: string; + providerCallId: string; + from: string; +}): NormalizedEvent { + return { + id: params.id, + type: "call.initiated", + callId: params.providerCallId, + providerCallId: params.providerCallId, + timestamp: Date.now(), + direction: "inbound", + from: params.from, + to: "+15550000000", + }; +} + +function createRejectingInboundContext(): { + ctx: CallManagerContext; + hangupCalls: HangupCallInput[]; +} { + const hangupCalls: HangupCallInput[] = []; + const provider = createProvider({ + hangupCall: async (input: HangupCallInput): Promise => { + hangupCalls.push(input); + }, + }); + const ctx = createContext({ + config: createInboundDisabledConfig(), + provider, + }); + return { ctx, hangupCalls }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - - const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), - provider, - }); - const event: NormalizedEvent = { + const { ctx, hangupCalls } = createRejectingInboundContext(); + const event = createInboundInitiatedEvent({ id: "evt-1", - type: "call.initiated", - callId: "prov-1", providerCallId: "prov-1", - timestamp: Date.now(), - direction: "inbound", from: "+15559999999", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -87,24 +110,14 @@ describe("processEvent (functional)", () => { it("does not call hangup when provider is null", () => { const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider: null, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-2", - type: "call.initiated", - callId: "prov-2", providerCallId: "prov-2", - timestamp: Date.now(), - direction: "inbound", from: "+15551111111", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -112,31 +125,12 @@ describe("processEvent (functional)", () => { }); it("calls hangup only once for duplicate events for same rejected call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), - provider, - }); - const event1: NormalizedEvent = { + const { ctx, hangupCalls } = createRejectingInboundContext(); + const event1 = createInboundInitiatedEvent({ id: "evt-init", - type: "call.initiated", - callId: "prov-dup", providerCallId: "prov-dup", - timestamp: Date.now(), - direction: "inbound", from: "+15552222222", - to: "+15550000000", - }; + }); const event2: NormalizedEvent = { id: "evt-ring", type: "call.ringing", @@ -228,26 +222,61 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-fail", - type: "call.initiated", - callId: "prov-fail", providerCallId: "prov-fail", - timestamp: Date.now(), - direction: "inbound", from: "+15553333333", - to: "+15550000000", - }; + }); expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); }); + + it("deduplicates by dedupeKey even when event IDs differ", () => { + const now = Date.now(); + const ctx = createContext(); + ctx.activeCalls.set("call-dedupe", { + callId: "call-dedupe", + providerCallId: "provider-dedupe", + provider: "plivo", + direction: "outbound", + state: "answered", + from: "+15550000000", + to: "+15550000001", + startedAt: now, + transcript: [], + processedEventIds: [], + metadata: {}, + }); + ctx.providerCallIdMap.set("provider-dedupe", "call-dedupe"); + + processEvent(ctx, { + id: "evt-1", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 1, + transcript: "hello", + isFinal: true, + }); + + processEvent(ctx, { + id: "evt-2", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 2, + transcript: "hello", + isFinal: true, + }); + + const call = ctx.activeCalls.get("call-dedupe"); + expect(call?.transcript).toHaveLength(1); + expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]); + }); }); diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 508a8d526340..2d39a96bf749 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -92,10 +92,11 @@ function createInboundCall(params: { } export function processEvent(ctx: EventContext, event: NormalizedEvent): void { - if (ctx.processedEventIds.has(event.id)) { + const dedupeKey = event.dedupeKey || event.id; + if (ctx.processedEventIds.has(dedupeKey)) { return; } - ctx.processedEventIds.add(event.id); + ctx.processedEventIds.add(dedupeKey); let call = findCall({ activeCalls: ctx.activeCalls, @@ -158,7 +159,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { } } - call.processedEventIds.push(event.id); + call.processedEventIds.push(dedupeKey); switch (event.type) { case "call.initiated": @@ -192,8 +193,20 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { case "call.speech": if (event.isFinal) { + const hadWaiter = ctx.transcriptWaiters.has(call.callId); + const resolved = resolveTranscriptWaiter( + ctx, + call.callId, + event.transcript, + event.turnToken, + ); + if (hadWaiter && !resolved) { + console.warn( + `[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`, + ); + break; + } addTranscriptEntry(call, "user", event.transcript); - resolveTranscriptWaiter(ctx, call.callId, event.transcript); } transitionState(call, "listening"); break; diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index d94c9da99ed5..494d7a10b5d3 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -51,6 +51,57 @@ type EndCallContext = Pick< | "maxDurationTimers" >; +type ConnectedCallContext = Pick; + +type ConnectedCallLookup = + | { kind: "error"; error: string } + | { kind: "ended"; call: CallRecord } + | { + kind: "ok"; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + +type ConnectedCallResolution = + | { ok: false; error: string } + | { + ok: true; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + +function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { + const call = ctx.activeCalls.get(callId); + if (!call) { + return { kind: "error", error: "Call not found" }; + } + if (!ctx.provider || !call.providerCallId) { + return { kind: "error", error: "Call not connected" }; + } + if (TerminalStates.has(call.state)) { + return { kind: "ended", call }; + } + return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; +} + +function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution { + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { ok: false, error: lookup.error }; + } + if (lookup.kind === "ended") { + return { ok: false, error: "Call has ended" }; + } + return { + ok: true, + call: lookup.call, + providerCallId: lookup.providerCallId, + provider: lookup.provider, + }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -149,26 +200,22 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; - } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { - return { success: false, error: "Call has ended" }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } + const { call, providerCallId, provider } = connected; + try { transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); addTranscriptEntry(call, "bot", text); - const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; - await ctx.provider.playTts({ + const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; + await provider.playTts({ callId, - providerCallId: call.providerCallId, + providerCallId, text, voice, }); @@ -232,22 +279,19 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; - } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { - return { success: false, error: "Call has ended" }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } + const { call, providerCallId, provider } = connected; + if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; } ctx.activeTurnCalls.add(callId); const turnStartedAt = Date.now(); + const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined; try { await speak(ctx, callId, prompt); @@ -256,13 +300,13 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await ctx.provider.startListening({ callId, providerCallId: call.providerCallId }); + await provider.startListening({ callId, providerCallId, turnToken }); - const transcript = await waitForFinalTranscript(ctx, callId); + const transcript = await waitForFinalTranscript(ctx, callId, turnToken); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. - await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId }); + await provider.stopListening({ callId, providerCallId }); const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt; const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt; @@ -302,21 +346,19 @@ export async function endCall( ctx: EndCallContext, callId: CallId, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: true }; } + const { call, providerCallId, provider } = lookup; try { - await ctx.provider.hangupCall({ + await provider.hangupCall({ callId, - providerCallId: call.providerCallId, + providerCallId, reason: "hangup-bot", }); @@ -329,9 +371,7 @@ export async function endCall( rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); ctx.activeCalls.delete(callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } + ctx.providerCallIdMap.delete(providerCallId); return { success: true }; } catch (err) { diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 236ffa143547..595ddb993f4d 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -77,16 +77,25 @@ export function resolveTranscriptWaiter( ctx: TranscriptWaiterContext, callId: CallId, transcript: string, -): void { + turnToken?: string, +): boolean { const waiter = ctx.transcriptWaiters.get(callId); if (!waiter) { - return; + return false; + } + if (waiter.turnToken && waiter.turnToken !== turnToken) { + return false; } clearTranscriptWaiter(ctx, callId); waiter.resolve(transcript); + return true; } -export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise { +export function waitForFinalTranscript( + ctx: TimerContext, + callId: CallId, + turnToken?: string, +): Promise { if (ctx.transcriptWaiters.has(callId)) { return Promise.reject(new Error("Already waiting for transcript")); } @@ -98,6 +107,6 @@ export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promi reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`)); }, timeoutMs); - ctx.transcriptWaiters.set(callId, { resolve, reject, timeout }); + ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken }); }); } diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index ac2c5e53733c..ecd4727318c6 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -1,4 +1,7 @@ +import { once } from "node:events"; +import http from "node:http"; import { describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; import { MediaStreamHandler } from "./media-stream.js"; import type { OpenAIRealtimeSTTProvider, @@ -34,6 +37,70 @@ const waitForAbort = (signal: AbortSignal): Promise => signal.addEventListener("abort", () => resolve(), { once: true }); }); +const withTimeout = async (promise: Promise, timeoutMs = 2000): Promise => { + let timer: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +}; + +const startWsServer = async ( + handler: MediaStreamHandler, +): Promise<{ + url: string; + close: () => Promise; +}> => { + const server = http.createServer(); + server.on("upgrade", (request, socket, head) => { + handler.handleUpgrade(request, socket, head); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}/voice/stream`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +}; + +const connectWs = async (url: string): Promise => { + const ws = new WebSocket(url); + await withTimeout(once(ws, "open") as Promise<[unknown]>); + return ws; +}; + +const waitForClose = async ( + ws: WebSocket, +): Promise<{ + code: number; + reason: string; +}> => { + const [code, reason] = (await withTimeout(once(ws, "close") as Promise<[number, Buffer]>)) ?? []; + return { + code, + reason: Buffer.isBuffer(reason) ? reason.toString() : String(reason || ""), + }; +}; + describe("MediaStreamHandler TTS queue", () => { it("serializes TTS playback and resolves in order", async () => { const handler = new MediaStreamHandler({ @@ -94,3 +161,111 @@ describe("MediaStreamHandler TTS queue", () => { expect(queuedRan).toBe(false); }); }); + +describe("MediaStreamHandler security hardening", () => { + it("closes idle pre-start connections after timeout", async () => { + const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> = + []; + const handler = new MediaStreamHandler({ + sttProvider: createStubSttProvider(), + preStartTimeoutMs: 40, + shouldAcceptStream: (params) => { + shouldAcceptStreamCalls.push(params); + return true; + }, + }); + const server = await startWsServer(handler); + + try { + const ws = await connectWs(server.url); + const closed = await waitForClose(ws); + + expect(closed.code).toBe(1008); + expect(closed.reason).toBe("Start timeout"); + expect(shouldAcceptStreamCalls).toEqual([]); + } finally { + await server.close(); + } + }); + + it("enforces pending connection limits", async () => { + const handler = new MediaStreamHandler({ + sttProvider: createStubSttProvider(), + preStartTimeoutMs: 5_000, + maxPendingConnections: 1, + maxPendingConnectionsPerIp: 1, + }); + const server = await startWsServer(handler); + + try { + const first = await connectWs(server.url); + const second = await connectWs(server.url); + const secondClosed = await waitForClose(second); + + expect(secondClosed.code).toBe(1013); + expect(secondClosed.reason).toContain("Too many pending"); + expect(first.readyState).toBe(WebSocket.OPEN); + + first.close(); + await waitForClose(first); + } finally { + await server.close(); + } + }); + + it("rejects upgrades when max connection cap is reached", async () => { + const handler = new MediaStreamHandler({ + sttProvider: createStubSttProvider(), + preStartTimeoutMs: 5_000, + maxConnections: 1, + maxPendingConnections: 10, + maxPendingConnectionsPerIp: 10, + }); + const server = await startWsServer(handler); + + try { + const first = await connectWs(server.url); + const secondError = await withTimeout( + new Promise((resolve) => { + const ws = new WebSocket(server.url); + ws.once("error", (err) => resolve(err as Error)); + }), + ); + + expect(secondError.message).toContain("Unexpected server response: 503"); + + first.close(); + await waitForClose(first); + } finally { + await server.close(); + } + }); + + it("clears pending state after valid start", async () => { + const handler = new MediaStreamHandler({ + sttProvider: createStubSttProvider(), + preStartTimeoutMs: 40, + shouldAcceptStream: () => true, + }); + const server = await startWsServer(handler); + + try { + const ws = await connectWs(server.url); + ws.send( + JSON.stringify({ + event: "start", + streamSid: "MZ123", + start: { callSid: "CA123", customParameters: { token: "token-123" } }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(ws.readyState).toBe(WebSocket.OPEN); + + ws.close(); + await waitForClose(ws); + } finally { + await server.close(); + } + }); +}); diff --git a/extensions/voice-call/src/media-stream.ts b/extensions/voice-call/src/media-stream.ts index ebb0ed9d8443..11fa0109c12f 100644 --- a/extensions/voice-call/src/media-stream.ts +++ b/extensions/voice-call/src/media-stream.ts @@ -21,6 +21,14 @@ import type { export interface MediaStreamConfig { /** STT provider for transcription */ sttProvider: OpenAIRealtimeSTTProvider; + /** Close sockets that never send a valid `start` frame within this window. */ + preStartTimeoutMs?: number; + /** Max concurrent pre-start sockets. */ + maxPendingConnections?: number; + /** Max concurrent pre-start sockets from a single source IP. */ + maxPendingConnectionsPerIp?: number; + /** Max total open sockets (pending + active sessions). */ + maxConnections?: number; /** Validate whether to accept a media stream for the given call ID */ shouldAcceptStream?: (params: { callId: string; streamSid: string; token?: string }) => boolean; /** Callback when transcript is received */ @@ -52,6 +60,16 @@ type TtsQueueEntry = { reject: (error: unknown) => void; }; +type PendingConnection = { + ip: string; + timeout: ReturnType; +}; + +const DEFAULT_PRE_START_TIMEOUT_MS = 5000; +const DEFAULT_MAX_PENDING_CONNECTIONS = 32; +const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4; +const DEFAULT_MAX_CONNECTIONS = 128; + /** * Manages WebSocket connections for Twilio media streams. */ @@ -59,6 +77,14 @@ export class MediaStreamHandler { private wss: WebSocketServer | null = null; private sessions = new Map(); private config: MediaStreamConfig; + /** Pending sockets that have upgraded but not yet sent an accepted `start` frame. */ + private pendingConnections = new Map(); + /** Pending socket count per remote IP for pre-auth throttling. */ + private pendingByIp = new Map(); + private preStartTimeoutMs: number; + private maxPendingConnections: number; + private maxPendingConnectionsPerIp: number; + private maxConnections: number; /** TTS playback queues per stream (serialize audio to prevent overlap) */ private ttsQueues = new Map(); /** Whether TTS is currently playing per stream */ @@ -68,6 +94,11 @@ export class MediaStreamHandler { constructor(config: MediaStreamConfig) { this.config = config; + this.preStartTimeoutMs = config.preStartTimeoutMs ?? DEFAULT_PRE_START_TIMEOUT_MS; + this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS; + this.maxPendingConnectionsPerIp = + config.maxPendingConnectionsPerIp ?? DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP; + this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS; } /** @@ -79,6 +110,12 @@ export class MediaStreamHandler { this.wss.on("connection", (ws, req) => this.handleConnection(ws, req)); } + const currentConnections = this.wss.clients.size; + if (currentConnections >= this.maxConnections) { + this.rejectUpgrade(socket, 503, "Too many media stream connections"); + return; + } + this.wss.handleUpgrade(request, socket, head, (ws) => { this.wss?.emit("connection", ws, request); }); @@ -90,6 +127,12 @@ export class MediaStreamHandler { private async handleConnection(ws: WebSocket, _request: IncomingMessage): Promise { let session: StreamSession | null = null; const streamToken = this.getStreamToken(_request); + const ip = this.getClientIp(_request); + + if (!this.registerPendingConnection(ws, ip)) { + ws.close(1013, "Too many pending media stream connections"); + return; + } ws.on("message", async (data: Buffer) => { try { @@ -102,6 +145,9 @@ export class MediaStreamHandler { case "start": session = await this.handleStart(ws, message, streamToken); + if (session) { + this.clearPendingConnection(ws); + } break; case "media": @@ -125,6 +171,7 @@ export class MediaStreamHandler { }); ws.on("close", () => { + this.clearPendingConnection(ws); if (session) { this.handleStop(session); } @@ -226,6 +273,69 @@ export class MediaStreamHandler { } } + private getClientIp(request: IncomingMessage): string { + return request.socket.remoteAddress || "unknown"; + } + + private registerPendingConnection(ws: WebSocket, ip: string): boolean { + if (this.pendingConnections.size >= this.maxPendingConnections) { + console.warn("[MediaStream] Rejecting connection: pending connection limit reached"); + return false; + } + + const pendingForIp = this.pendingByIp.get(ip) ?? 0; + if (pendingForIp >= this.maxPendingConnectionsPerIp) { + console.warn(`[MediaStream] Rejecting connection: pending per-IP limit reached (${ip})`); + return false; + } + + const timeout = setTimeout(() => { + if (!this.pendingConnections.has(ws)) { + return; + } + console.warn( + `[MediaStream] Closing pre-start idle connection after ${this.preStartTimeoutMs}ms (${ip})`, + ); + ws.close(1008, "Start timeout"); + }, this.preStartTimeoutMs); + + timeout.unref?.(); + this.pendingConnections.set(ws, { ip, timeout }); + this.pendingByIp.set(ip, pendingForIp + 1); + return true; + } + + private clearPendingConnection(ws: WebSocket): void { + const pending = this.pendingConnections.get(ws); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingConnections.delete(ws); + + const current = this.pendingByIp.get(pending.ip) ?? 0; + if (current <= 1) { + this.pendingByIp.delete(pending.ip); + return; + } + this.pendingByIp.set(pending.ip, current - 1); + } + + private rejectUpgrade(socket: Duplex, statusCode: 429 | 503, message: string): void { + const statusText = statusCode === 429 ? "Too Many Requests" : "Service Unavailable"; + const body = `${message}\n`; + socket.write( + `HTTP/1.1 ${statusCode} ${statusText}\r\n` + + "Connection: close\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + "\r\n" + + body, + ); + socket.destroy(); + } + /** * Get an active session with an open WebSocket, or undefined if unavailable. */ diff --git a/extensions/voice-call/src/providers/base.ts b/extensions/voice-call/src/providers/base.ts index 63a9a0471813..2d76cc15a7e6 100644 --- a/extensions/voice-call/src/providers/base.ts +++ b/extensions/voice-call/src/providers/base.ts @@ -4,6 +4,7 @@ import type { InitiateCallResult, PlayTtsInput, ProviderName, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -36,7 +37,7 @@ export interface VoiceCallProvider { * Parse provider-specific webhook payload into normalized events. * Returns events and optional response to send back to provider. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult; + parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult; /** * Initiate an outbound call. diff --git a/extensions/voice-call/src/providers/mock.ts b/extensions/voice-call/src/providers/mock.ts index bc6a52efa711..6602d6e71f96 100644 --- a/extensions/voice-call/src/providers/mock.ts +++ b/extensions/voice-call/src/providers/mock.ts @@ -6,6 +6,7 @@ import type { InitiateCallResult, NormalizedEvent, PlayTtsInput, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider { return { ok: true }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + _options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const events: NormalizedEvent[] = []; diff --git a/extensions/voice-call/src/providers/plivo.test.ts b/extensions/voice-call/src/providers/plivo.test.ts index 1f46e2d47a5c..7652c3777cd1 100644 --- a/extensions/voice-call/src/providers/plivo.test.ts +++ b/extensions/voice-call/src/providers/plivo.test.ts @@ -24,4 +24,26 @@ describe("PlivoProvider", () => { expect(result.providerResponseBody).toContain(" { + const provider = new PlivoProvider({ + authId: "MA000000000000000000", + authToken: "test-token", + }); + + const result = provider.parseWebhookEvent( + { + headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" }, + rawBody: + "CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp", + url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id", + method: "POST", + query: { provider: "plivo", flow: "answer", callId: "internal-call-id" }, + }, + { verifiedRequestKey: "plivo:v3:verified" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified"); + }); }); diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 9739379cf584..6db603d06392 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import type { PlivoConfig, WebhookSecurityConfig } from "../config.js"; +import { getHeader } from "../http-headers.js"; import type { HangupCallInput, InitiateCallInput, @@ -10,11 +11,13 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml } from "../voice-mapping.js"; import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js"; import type { VoiceCallProvider } from "./base.js"; +import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; export interface PlivoProviderOptions { /** Override public URL origin for signature verification */ @@ -30,6 +33,18 @@ export interface PlivoProviderOptions { type PendingSpeak = { text: string; locale?: string }; type PendingListen = { language?: string }; +function createPlivoRequestDedupeKey(ctx: WebhookContext): string { + const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); + if (nonceV3) { + return `plivo:v3:${nonceV3}`; + } + const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce"); + if (nonceV2) { + return `plivo:v2:${nonceV2}`; + } + return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`; +} + export class PlivoProvider implements VoiceCallProvider { readonly name = "plivo" as const; @@ -37,6 +52,7 @@ export class PlivoProvider implements VoiceCallProvider { private readonly authToken: string; private readonly baseUrl: string; private readonly options: PlivoProviderOptions; + private readonly apiHost: string; // Best-effort mapping between create-call request UUID and call UUID. private requestUuidToCallUuid = new Map(); @@ -59,6 +75,7 @@ export class PlivoProvider implements VoiceCallProvider { this.authId = config.authId; this.authToken = config.authToken; this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`; + this.apiHost = new URL(this.baseUrl).hostname; this.options = options; } @@ -69,25 +86,19 @@ export class PlivoProvider implements VoiceCallProvider { allowNotFound?: boolean; }): Promise { const { method, endpoint, body, allowNotFound } = params; - const response = await fetch(`${this.baseUrl}${endpoint}`, { + return await guardedJsonApiRequest({ + url: `${this.baseUrl}${endpoint}`, method, headers: { Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, "Content-Type": "application/json", }, - body: body ? JSON.stringify(body) : undefined, + body, + allowNotFound, + allowedHostnames: [this.apiHost], + auditContext: "voice-call.plivo.api", + errorPrefix: "Plivo API error", }); - - if (!response.ok) { - if (allowNotFound && response.status === 404) { - return undefined as T; - } - const errorText = await response.text(); - throw new Error(`Plivo API error: ${response.status} ${errorText}`); - } - - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); } verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { @@ -104,10 +115,18 @@ export class PlivoProvider implements VoiceCallProvider { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } - return { ok: result.ok, reason: result.reason }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const parsed = this.parseBody(ctx.rawBody); @@ -173,7 +192,8 @@ export class PlivoProvider implements VoiceCallProvider { // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); - const event = this.normalizeEvent(parsed, callIdFromQuery); + const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx); + const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { events: event ? [event] : [], @@ -186,7 +206,11 @@ export class PlivoProvider implements VoiceCallProvider { }; } - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + callIdOverride?: string, + dedupeKey?: string, + ): NormalizedEvent | null { const callUuid = params.get("CallUUID") || ""; const requestUuid = params.get("RequestUUID") || ""; @@ -201,6 +225,7 @@ export class PlivoProvider implements VoiceCallProvider { const baseEvent = { id: crypto.randomUUID(), + dedupeKey, callId: callIdOverride || callUuid || requestUuid, providerCallId: callUuid || requestUuid || undefined, timestamp: Date.now(), @@ -331,31 +356,40 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async playTts(input: PlayTtsInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; + private resolveCallContext(params: { + providerCallId: string; + callId: string; + operation: string; + }): { + callUuid: string; + webhookBase: string; + } { + const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId; const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); + this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId); if (!webhookBase) { throw new Error("Missing webhook URL for this call (provider state missing)"); } - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for playTts"); + throw new Error(`Missing Plivo CallUUID for ${params.operation}`); } + return { callUuid, webhookBase }; + } - const transferUrl = new URL(webhookBase); + private async transferCallLeg(params: { + callUuid: string; + webhookBase: string; + callId: string; + flow: "xml-speak" | "xml-listen"; + }): Promise { + const transferUrl = new URL(params.webhookBase); transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-speak"); - transferUrl.searchParams.set("callId", input.callId); - - this.pendingSpeakByCallId.set(input.callId, { - text: input.text, - locale: input.locale, - }); + transferUrl.searchParams.set("flow", params.flow); + transferUrl.searchParams.set("callId", params.callId); await this.apiRequest({ method: "POST", - endpoint: `/Call/${callUuid}/`, + endpoint: `/Call/${params.callUuid}/`, body: { legs: "aleg", aleg_url: transferUrl.toString(), @@ -364,35 +398,42 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async startListening(input: StartListeningInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; - const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); - if (!webhookBase) { - throw new Error("Missing webhook URL for this call (provider state missing)"); - } + async playTts(input: PlayTtsInput): Promise { + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "playTts", + }); - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for startListening"); - } + this.pendingSpeakByCallId.set(input.callId, { + text: input.text, + locale: input.locale, + }); - const transferUrl = new URL(webhookBase); - transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-listen"); - transferUrl.searchParams.set("callId", input.callId); + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-speak", + }); + } + + async startListening(input: StartListeningInput): Promise { + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "startListening", + }); this.pendingListenByCallId.set(input.callId, { language: input.language, }); - await this.apiRequest({ - method: "POST", - endpoint: `/Call/${callUuid}/`, - body: { - legs: "aleg", - aleg_url: transferUrl.toString(), - aleg_method: "POST", - }, + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-listen", }); } diff --git a/extensions/voice-call/src/providers/shared/guarded-json-api.ts b/extensions/voice-call/src/providers/shared/guarded-json-api.ts new file mode 100644 index 000000000000..6790cae5d76c --- /dev/null +++ b/extensions/voice-call/src/providers/shared/guarded-json-api.ts @@ -0,0 +1,42 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; + +type GuardedJsonApiRequestParams = { + url: string; + method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH"; + headers: Record; + body?: Record; + allowNotFound?: boolean; + allowedHostnames: string[]; + auditContext: string; + errorPrefix: string; +}; + +export async function guardedJsonApiRequest( + params: GuardedJsonApiRequestParams, +): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + init: { + method: params.method, + headers: params.headers, + body: params.body ? JSON.stringify(params.body) : undefined, + }, + policy: { allowedHostnames: params.allowedHostnames }, + auditContext: params.auditContext, + }); + + try { + if (!response.ok) { + if (params.allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`); + } + + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } +} diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index e1a4524d2803..c083070229f4 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -103,4 +103,64 @@ describe("TelnyxProvider.verifyWebhook", () => { const spkiDerBase64 = spkiDer.toString("base64"); expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey }); }); + + it("returns replay status when the same signed request is seen twice", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "call-replay-test" }, + nonce: crypto.randomUUID(), + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }); + + const first = provider.verifyWebhook(ctx); + const second = provider.verifyWebhook(ctx); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); +}); + +describe("TelnyxProvider.parseWebhookEvent", () => { + it("uses verified request key for manager dedupe", () => { + const provider = new TelnyxProvider({ + apiKey: "KEY123", + connectionId: "CONN456", + publicKey: undefined, + }); + const result = provider.parseWebhookEvent( + createCtx({ + rawBody: JSON.stringify({ + data: { + id: "evt-123", + event_type: "call.initiated", + payload: { call_control_id: "call-1" }, + }, + }), + }), + { verifiedRequestKey: "telnyx:req:abc" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc"); + }); }); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 05a750a00bb8..80a46ce21929 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -11,10 +11,12 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { verifyTelnyxWebhook } from "../webhook-security.js"; import type { VoiceCallProvider } from "./base.js"; +import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; /** * Telnyx Voice API provider implementation. @@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider { private readonly publicKey: string | undefined; private readonly options: TelnyxProviderOptions; private readonly baseUrl = "https://api.telnyx.com/v2"; + private readonly apiHost = "api.telnyx.com"; constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) { if (!config.apiKey) { @@ -58,25 +61,19 @@ export class TelnyxProvider implements VoiceCallProvider { body: Record, options?: { allowNotFound?: boolean }, ): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`, { + return await guardedJsonApiRequest({ + url: `${this.baseUrl}${endpoint}`, method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify(body), + body, + allowNotFound: options?.allowNotFound, + allowedHostnames: [this.apiHost], + auditContext: "voice-call.telnyx.api", + errorPrefix: "Telnyx API error", }); - - if (!response.ok) { - if (options?.allowNotFound && response.status === 404) { - return undefined as T; - } - const errorText = await response.text(); - throw new Error(`Telnyx API error: ${response.status} ${errorText}`); - } - - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); } /** @@ -87,13 +84,21 @@ export class TelnyxProvider implements VoiceCallProvider { skipVerification: this.options.skipVerification, }); - return { ok: result.ok, reason: result.reason }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } /** * Parse Telnyx webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const data = payload.data; @@ -102,7 +107,7 @@ export class TelnyxProvider implements VoiceCallProvider { return { events: [], statusCode: 200 }; } - const event = this.normalizeEvent(data); + const event = this.normalizeEvent(data, options?.verifiedRequestKey); return { events: event ? [event] : [], statusCode: 200, @@ -115,7 +120,7 @@ export class TelnyxProvider implements VoiceCallProvider { /** * Convert Telnyx event to normalized event format. */ - private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null { + private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null { // Decode client_state from Base64 (we encode it in initiateCall) let callId = ""; if (data.payload?.client_state) { @@ -132,6 +137,7 @@ export class TelnyxProvider implements VoiceCallProvider { const baseEvent = { id: data.id || crypto.randomUUID(), + dedupeKey, callId, providerCallId: data.payload?.call_control_id, timestamp: Date.now(), diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 3a5652a35636..92cbe0fec324 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -59,4 +59,59 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain('"); }); + + it("uses a stable fallback dedupeKey for identical request payloads", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA).events[0]; + const eventB = provider.parseWebhookEvent(ctxB).events[0]; + + expect(eventA).toBeDefined(); + expect(eventB).toBeDefined(); + expect(eventA?.id).not.toBe(eventB?.id); + expect(eventA?.dedupeKey).toContain("twilio:fallback:"); + expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey); + }); + + it("uses verified request key for dedupe and ignores idempotency header changes", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-a" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-b" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + + expect(eventA?.dedupeKey).toBe("twilio:req:abc"); + expect(eventB?.dedupeKey).toBe("twilio:req:abc"); + }); + + it("keeps turnToken from query on speech events", () => { + const provider = createProvider(); + const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", { + callId: "call-2", + turnToken: "turn-xyz", + }); + + const event = provider.parseWebhookEvent(ctx).events[0]; + expect(event?.type).toBe("call.speech"); + expect(event?.turnToken).toBe("turn-xyz"); + }); }); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 45031c351428..bf5515677227 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import type { TwilioConfig, WebhookSecurityConfig } from "../config.js"; +import { getHeader } from "../http-headers.js"; import type { MediaStreamHandler } from "../media-stream.js"; import { chunkAudio } from "../telephony-audio.js"; import type { TelephonyTtsProvider } from "../telephony-tts.js"; @@ -13,6 +14,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; @@ -20,6 +22,27 @@ import type { VoiceCallProvider } from "./base.js"; import { twilioApiRequest } from "./twilio/api.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; +function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string { + if (verifiedRequestKey) { + return verifiedRequestKey; + } + + const signature = getHeader(ctx.headers, "x-twilio-signature") ?? ""; + const params = new URLSearchParams(ctx.rawBody); + const callSid = params.get("CallSid") ?? ""; + const callStatus = params.get("CallStatus") ?? ""; + const direction = params.get("Direction") ?? ""; + const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : ""; + const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; + const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : ""; + return `twilio:fallback:${crypto + .createHash("sha256") + .update( + `${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`, + ) + .digest("hex")}`; +} + /** * Twilio Voice API provider implementation. * @@ -205,14 +228,26 @@ export class TwilioProvider implements VoiceCallProvider { /** * Parse Twilio webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const params = new URLSearchParams(ctx.rawBody); const callIdFromQuery = typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; - const event = this.normalizeEvent(params, callIdFromQuery); + const turnTokenFromQuery = + typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim() + ? ctx.query.turnToken.trim() + : undefined; + const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey); + const event = this.normalizeEvent(params, { + callIdOverride: callIdFromQuery, + dedupeKey, + turnToken: turnTokenFromQuery, + }); // For Twilio, we must return TwiML. Most actions are driven by Calls API updates, // so the webhook response is typically a pause to keep the call alive. @@ -245,14 +280,24 @@ export class TwilioProvider implements VoiceCallProvider { /** * Convert Twilio webhook params to normalized event format. */ - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + options?: { + callIdOverride?: string; + dedupeKey?: string; + turnToken?: string; + }, + ): NormalizedEvent | null { const callSid = params.get("CallSid") || ""; + const callIdOverride = options?.callIdOverride; const baseEvent = { id: crypto.randomUUID(), + dedupeKey: options?.dedupeKey, callId: callIdOverride || callSid, providerCallId: callSid, timestamp: Date.now(), + turnToken: options?.turnToken, direction: TwilioProvider.parseDirection(params.get("Direction")), from: params.get("From") || undefined, to: params.get("To") || undefined, @@ -603,9 +648,14 @@ export class TwilioProvider implements VoiceCallProvider { throw new Error("Missing webhook URL for this call (provider state not initialized)"); } + const actionUrl = new URL(webhookUrl); + if (input.turnToken) { + actionUrl.searchParams.set("turnToken", input.turnToken); + } + const twiml = ` - + `; diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 91fdfb2dc1e5..4b38050959bb 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -28,5 +28,7 @@ export function verifyTwilioProviderWebhook(params: { return { ok: result.ok, reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, }; } diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 38091baa4d44..6806b7cc7286 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -74,9 +74,13 @@ export type EndReason = z.infer; const BaseEventSchema = z.object({ id: z.string(), + // Stable provider-derived key for idempotency/replay dedupe. + dedupeKey: z.string().optional(), callId: z.string(), providerCallId: z.string().optional(), timestamp: z.number(), + // Optional per-turn nonce for speech events (Twilio replay hardening). + turnToken: z.string().optional(), // Optional fields for inbound call detection direction: z.enum(["inbound", "outbound"]).optional(), from: z.string().optional(), @@ -171,6 +175,15 @@ export type CallRecord = z.infer; export type WebhookVerificationResult = { ok: boolean; reason?: string; + /** Signature is valid, but request was seen before within replay window. */ + isReplay?: boolean; + /** Stable key derived from authenticated request material. */ + verifiedRequestKey?: string; +}; + +export type WebhookParseOptions = { + /** Stable request key from verifyWebhook. */ + verifiedRequestKey?: string; }; export type WebhookContext = { @@ -226,6 +239,8 @@ export type StartListeningInput = { callId: CallId; providerCallId: ProviderCallId; language?: string; + /** Optional per-turn nonce for provider callbacks (replay hardening). */ + turnToken?: string; }; export type StopListeningInput = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 9ad662726a15..dd7fb69502e3 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -1,6 +1,10 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; -import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js"; +import { + verifyPlivoWebhook, + verifyTelnyxWebhook, + verifyTwilioWebhook, +} from "./webhook-security.js"; function canonicalizeBase64(input: string): string { return Buffer.from(input, "base64").toString("base64"); @@ -163,6 +167,107 @@ describe("verifyPlivoWebhook", () => { expect(result.ok).toBe(false); expect(result.reason).toMatch(/Missing Plivo signature headers/); }); + + it("marks replayed valid V3 requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const nonce = "nonce-replay-v3"; + const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc"; + const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; + const signature = plivoV3Signature({ + authToken, + urlWithQuery, + postBody, + nonce, + }); + + const ctx = { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-plivo-signature-v3": signature, + "x-plivo-signature-v3-nonce": nonce, + }, + rawBody: postBody, + url: urlWithQuery, + method: "POST" as const, + query: { flow: "answer", callId: "abc" }, + }; + + const first = verifyPlivoWebhook(ctx, authToken); + const second = verifyPlivoWebhook(ctx, authToken); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + + it("returns a stable request key when verification is skipped", () => { + const ctx = { + headers: {}, + rawBody: "CallUUID=uuid&CallStatus=in-progress", + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + const first = verifyPlivoWebhook(ctx, "token", { skipVerification: true }); + const second = verifyPlivoWebhook(ctx, "token", { skipVerification: true }); + + expect(first.ok).toBe(true); + expect(first.verifiedRequestKey).toMatch(/^plivo:skip:/); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expect(second.isReplay).toBe(true); + }); +}); + +describe("verifyTelnyxWebhook", () => { + it("marks replayed valid requests as replay without failing auth", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const rawBody = JSON.stringify({ + data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } }, + nonce: crypto.randomUUID(), + }); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = { + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + + const first = verifyTelnyxWebhook(ctx, pemPublicKey); + const second = verifyTelnyxWebhook(ctx, pemPublicKey); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + + it("returns a stable request key when verification is skipped", () => { + const ctx = { + headers: {}, + rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }), + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + const first = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true }); + const second = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true }); + + expect(first.ok).toBe(true); + expect(first.verifiedRequestKey).toMatch(/^telnyx:skip:/); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expect(second.isReplay).toBe(true); + }); }); describe("verifyTwilioWebhook", () => { @@ -197,6 +302,98 @@ describe("verifyTwilioWebhook", () => { expect(result.ok).toBe(true); }); + it("marks replayed valid requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS777&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + const headers = { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-1", + }; + + const first = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + + it("treats changed idempotency header as replay for identical signed requests", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + + const first = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-a", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-b", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBe(false); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + it("rejects invalid signatures even when attacker injects forwarded host", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; @@ -406,4 +603,20 @@ describe("verifyTwilioWebhook", () => { expect(result.ok).toBe(false); expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook"); }); + + it("returns a stable request key when verification is skipped", () => { + const ctx = { + headers: {}, + rawBody: "CallSid=CS123&CallStatus=completed", + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + const first = verifyTwilioWebhook(ctx, "token", { skipVerification: true }); + const second = verifyTwilioWebhook(ctx, "token", { skipVerification: true }); + + expect(first.ok).toBe(true); + expect(first.verifiedRequestKey).toMatch(/^twilio:skip:/); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expect(second.isReplay).toBe(true); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 7a8eccda5ae2..75d1ca490d0c 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,6 +1,73 @@ import crypto from "node:crypto"; +import { getHeader } from "./http-headers.js"; import type { WebhookContext } from "./types.js"; +const REPLAY_WINDOW_MS = 10 * 60 * 1000; +const REPLAY_CACHE_MAX_ENTRIES = 10_000; +const REPLAY_CACHE_PRUNE_INTERVAL = 64; + +type ReplayCache = { + seenUntil: Map; + calls: number; +}; + +const twilioReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +const plivoReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +const telnyxReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +function sha256Hex(input: string): string { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string { + return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`; +} + +function pruneReplayCache(cache: ReplayCache, now: number): void { + for (const [key, expiresAt] of cache.seenUntil) { + if (expiresAt <= now) { + cache.seenUntil.delete(key); + } + } + while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + const oldest = cache.seenUntil.keys().next().value; + if (!oldest) { + break; + } + cache.seenUntil.delete(oldest); + } +} + +function markReplay(cache: ReplayCache, replayKey: string): boolean { + const now = Date.now(); + cache.calls += 1; + if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) { + pruneReplayCache(cache, now); + } + + const existing = cache.seenUntil.get(replayKey); + if (existing && existing > now) { + return true; + } + + cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS); + if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + pruneReplayCache(cache, now); + } + return false; +} + /** * Validate Twilio webhook signature using HMAC-SHA1. * @@ -19,17 +86,7 @@ export function validateTwilioSignature( return false; } - // Build the string to sign: URL + sorted params (key+value pairs) - let dataToSign = url; - - // Sort params alphabetically and append key+value - const sortedParams = Array.from(params.entries()).toSorted((a, b) => - a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, - ); - - for (const [key, value] of sortedParams) { - dataToSign += key + value; - } + const dataToSign = buildTwilioDataToSign(url, params); // HMAC-SHA1 with auth token, then base64 encode const expectedSignature = crypto @@ -41,6 +98,24 @@ export function validateTwilioSignature( return timingSafeEqual(signature, expectedSignature); } +function buildTwilioDataToSign(url: string, params: URLSearchParams): string { + let dataToSign = url; + const sortedParams = Array.from(params.entries()).toSorted((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, + ); + for (const [key, value] of sortedParams) { + dataToSign += key + value; + } + return dataToSign; +} + +function buildCanonicalTwilioParamString(params: URLSearchParams): string { + return Array.from(params.entries()) + .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + /** * Timing-safe string comparison to prevent timing attacks. */ @@ -291,20 +366,6 @@ function buildTwilioVerificationUrl( } } -/** - * Get a header value, handling both string and string[] types. - */ -function getHeader( - headers: Record, - name: string, -): string | undefined { - const value = headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - function isLoopbackAddress(address?: string): boolean { if (!address) { return false; @@ -328,11 +389,30 @@ export interface TwilioVerificationResult { verificationUrl?: string; /** Whether we're running behind ngrok free tier */ isNgrokFreeTier?: boolean; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; + /** Stable request identity derived from signed Twilio material. */ + verifiedRequestKey?: string; } export interface TelnyxVerificationResult { ok: boolean; reason?: string; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; + /** Stable request identity derived from signed Telnyx material. */ + verifiedRequestKey?: string; +} + +function createTwilioReplayKey(params: { + verificationUrl: string; + signature: string; + requestParams: URLSearchParams; +}): string { + const canonicalParams = buildCanonicalTwilioParamString(params.requestParams); + return `twilio:req:${sha256Hex( + `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`, + )}`; } function decodeBase64OrBase64Url(input: string): Buffer { @@ -390,7 +470,14 @@ export function verifyTelnyxWebhook( }, ): TelnyxVerificationResult { if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("telnyx", ctx); + const isReplay = markReplay(telnyxReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } if (!publicKey) { @@ -426,7 +513,9 @@ export function verifyTelnyxWebhook( return { ok: false, reason: "Timestamp too old" }; } - return { ok: true }; + const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; + const isReplay = markReplay(telnyxReplayCache, replayKey); + return { ok: true, isReplay, verifiedRequestKey: replayKey }; } catch (err) { return { ok: false, @@ -478,7 +567,14 @@ export function verifyTwilioWebhook( ): TwilioVerificationResult { // Allow skipping verification for development/testing if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("twilio", ctx); + const isReplay = markReplay(twilioReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } const signature = getHeader(ctx.headers, "x-twilio-signature"); @@ -501,11 +597,16 @@ export function verifyTwilioWebhook( // Parse the body as URL-encoded params const params = new URLSearchParams(ctx.rawBody); - // Validate signature const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params); if (isValid) { - return { ok: true, verificationUrl }; + const replayKey = createTwilioReplayKey({ + verificationUrl, + signature, + requestParams: params, + }); + const isReplay = markReplay(twilioReplayCache, replayKey); + return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey }; } // Check if this is ngrok free tier - the URL might have different format @@ -533,6 +634,10 @@ export interface PlivoVerificationResult { verificationUrl?: string; /** Signature version used for verification */ version?: "v3" | "v2"; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; + /** Stable request identity derived from signed Plivo material. */ + verifiedRequestKey?: string; } function normalizeSignatureBase64(input: string): string { @@ -705,7 +810,14 @@ export function verifyPlivoWebhook( }, ): PlivoVerificationResult { if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("plivo", ctx); + const isReplay = markReplay(plivoReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3"); @@ -753,14 +865,17 @@ export function verifyPlivoWebhook( url: verificationUrl, postParams, }); - return ok - ? { ok: true, version: "v3", verificationUrl } - : { - ok: false, - version: "v3", - verificationUrl, - reason: "Invalid Plivo V3 signature", - }; + if (!ok) { + return { + ok: false, + version: "v3", + verificationUrl, + reason: "Invalid Plivo V3 signature", + }; + } + const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } if (signatureV2 && nonceV2) { @@ -770,14 +885,17 @@ export function verifyPlivoWebhook( nonce: nonceV2, url: verificationUrl, }); - return ok - ? { ok: true, version: "v2", verificationUrl } - : { - ok: false, - version: "v2", - verificationUrl, - reason: "Invalid Plivo V2 signature", - }; + if (!ok) { + return { + ok: false, + version: "v2", + verificationUrl, + reason: "Invalid Plivo V2 signature", + }; + } + const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } return { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 51afdb7eba01..759ff85d0103 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -7,7 +7,7 @@ import { VoiceCallWebhookServer } from "./webhook.js"; const provider: VoiceCallProvider = { name: "mock", - verifyWebhook: () => ({ ok: true }), + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }), parseWebhookEvent: () => ({ events: [] }), initiateCall: async () => ({ providerCallId: "provider-call", status: "initiated" }), hangupCall: async () => {}, @@ -45,12 +45,14 @@ const createCall = (startedAt: number): CallRecord => ({ const createManager = (calls: CallRecord[]) => { const endCall = vi.fn(async () => ({ success: true })); + const processEvent = vi.fn(); const manager = { getActiveCalls: () => calls, endCall, + processEvent, } as unknown as CallManager; - return { manager, endCall }; + return { manager, endCall, processEvent }; }; describe("VoiceCallWebhookServer stale call reaper", () => { @@ -116,3 +118,136 @@ describe("VoiceCallWebhookServer stale call reaper", () => { } }); }); + +describe("VoiceCallWebhookServer replay handling", () => { + it("acknowledges replayed webhook requests and skips event side effects", async () => { + const replayProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, isReplay: true, verifiedRequestKey: "mock:req:replay" }), + parseWebhookEvent: () => ({ + events: [ + { + id: "evt-replay", + dedupeKey: "stable-replay", + type: "call.speech", + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + }), + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, replayProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(processEvent).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); + + it("passes verified request key from verifyWebhook into parseWebhookEvent", async () => { + const parseWebhookEvent = vi.fn((_ctx: unknown, options?: { verifiedRequestKey?: string }) => ({ + events: [ + { + id: "evt-verified", + dedupeKey: options?.verifiedRequestKey, + type: "call.speech" as const, + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + })); + const verifiedProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "verified:req:123" }), + parseWebhookEvent, + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, verifiedProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(parseWebhookEvent).toHaveBeenCalledTimes(1); + expect(parseWebhookEvent.mock.calls[0]?.[1]).toEqual({ + verifiedRequestKey: "verified:req:123", + }); + expect(processEvent).toHaveBeenCalledTimes(1); + expect(processEvent.mock.calls[0]?.[0]?.dedupeKey).toBe("verified:req:123"); + } finally { + await server.stop(); + } + }); + + it("rejects requests when verification succeeds without a request key", async () => { + const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 })); + const badProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true }), + parseWebhookEvent, + }; + const { manager } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, badProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(401); + expect(parseWebhookEvent).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); +}); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index f9e18a9dacf9..95d6628b5a8f 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js"; import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js"; import type { TwilioProvider } from "./providers/twilio.js"; import type { NormalizedEvent, WebhookContext } from "./types.js"; +import { startStaleCallReaper } from "./webhook/stale-call-reaper.js"; const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024; @@ -28,7 +29,7 @@ export class VoiceCallWebhookServer { private manager: CallManager; private provider: VoiceCallProvider; private coreConfig: CoreConfig | null; - private staleCallReaperInterval: ReturnType | null = null; + private stopStaleCallReaper: (() => void) | null = null; /** Media stream handler for bidirectional audio (when streaming enabled) */ private mediaStreamHandler: MediaStreamHandler | null = null; @@ -77,6 +78,10 @@ export class VoiceCallWebhookServer { const streamConfig: MediaStreamConfig = { sttProvider, + preStartTimeoutMs: this.config.streaming?.preStartTimeoutMs, + maxPendingConnections: this.config.streaming?.maxPendingConnections, + maxPendingConnectionsPerIp: this.config.streaming?.maxPendingConnectionsPerIp, + maxConnections: this.config.streaming?.maxConnections, shouldAcceptStream: ({ callId, token }) => { const call = this.manager.getCallByProviderCallId(callId); if (!call) { @@ -192,9 +197,8 @@ export class VoiceCallWebhookServer { // Handle WebSocket upgrades for media streams if (this.mediaStreamHandler) { this.server.on("upgrade", (request, socket, head) => { - const url = new URL(request.url || "/", `http://${request.headers.host}`); - - if (url.pathname === streamPath) { + const path = this.getUpgradePathname(request); + if (path === streamPath) { console.log("[voice-call] WebSocket upgrade for media stream"); this.mediaStreamHandler?.handleUpgrade(request, socket, head); } else { @@ -214,48 +218,21 @@ export class VoiceCallWebhookServer { resolve(url); // Start the stale call reaper if configured - this.startStaleCallReaper(); + this.stopStaleCallReaper = startStaleCallReaper({ + manager: this.manager, + staleCallReaperSeconds: this.config.staleCallReaperSeconds, + }); }); }); } - /** - * Start a periodic reaper that ends calls older than the configured threshold. - * Catches calls stuck in unexpected states (e.g., notify-mode calls that never - * receive a terminal webhook from the provider). - */ - private startStaleCallReaper(): void { - const maxAgeSeconds = this.config.staleCallReaperSeconds; - if (!maxAgeSeconds || maxAgeSeconds <= 0) { - return; - } - - const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds - const maxAgeMs = maxAgeSeconds * 1000; - - this.staleCallReaperInterval = setInterval(() => { - const now = Date.now(); - for (const call of this.manager.getActiveCalls()) { - const age = now - call.startedAt; - if (age > maxAgeMs) { - console.log( - `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`, - ); - void this.manager.endCall(call.callId).catch((err) => { - console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err); - }); - } - } - }, CHECK_INTERVAL_MS); - } - /** * Stop the webhook server. */ async stop(): Promise { - if (this.staleCallReaperInterval) { - clearInterval(this.staleCallReaperInterval); - this.staleCallReaperInterval = null; + if (this.stopStaleCallReaper) { + this.stopStaleCallReaper(); + this.stopStaleCallReaper = null; } return new Promise((resolve) => { if (this.server) { @@ -269,6 +246,15 @@ export class VoiceCallWebhookServer { }); } + private getUpgradePathname(request: http.IncomingMessage): string | null { + try { + const host = request.headers.host || "localhost"; + return new URL(request.url || "/", `http://${host}`).pathname; + } catch { + return null; + } + } + /** * Handle incoming HTTP request. */ @@ -329,16 +315,28 @@ export class VoiceCallWebhookServer { res.end("Unauthorized"); return; } + if (!verification.verifiedRequestKey) { + console.warn("[voice-call] Webhook verification succeeded without request identity key"); + res.statusCode = 401; + res.end("Unauthorized"); + return; + } // Parse events - const result = this.provider.parseWebhookEvent(ctx); + const result = this.provider.parseWebhookEvent(ctx, { + verifiedRequestKey: verification.verifiedRequestKey, + }); // Process each event - for (const event of result.events) { - try { - this.manager.processEvent(event); - } catch (err) { - console.error(`[voice-call] Error processing event ${event.type}:`, err); + if (verification.isReplay) { + console.warn("[voice-call] Replay detected; skipping event side effects"); + } else { + for (const event of result.events) { + try { + this.manager.processEvent(event); + } catch (err) { + console.error(`[voice-call] Error processing event ${event.type}:`, err); + } } } diff --git a/extensions/voice-call/src/webhook/stale-call-reaper.ts b/extensions/voice-call/src/webhook/stale-call-reaper.ts new file mode 100644 index 000000000000..4c9661153d54 --- /dev/null +++ b/extensions/voice-call/src/webhook/stale-call-reaper.ts @@ -0,0 +1,33 @@ +import type { CallManager } from "../manager.js"; + +const CHECK_INTERVAL_MS = 30_000; + +export function startStaleCallReaper(params: { + manager: CallManager; + staleCallReaperSeconds?: number; +}): (() => void) | null { + const maxAgeSeconds = params.staleCallReaperSeconds; + if (!maxAgeSeconds || maxAgeSeconds <= 0) { + return null; + } + + const maxAgeMs = maxAgeSeconds * 1000; + const interval = setInterval(() => { + const now = Date.now(); + for (const call of params.manager.getActiveCalls()) { + const age = now - call.startedAt; + if (age > maxAgeMs) { + console.log( + `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`, + ); + void params.manager.endCall(call.callId).catch((err) => { + console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err); + }); + } + } + }, CHECK_INTERVAL_MS); + + return () => { + clearInterval(interval); + }; +} diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 819c3c2ab307..e2ba8ba8487d 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.22", + "version": "2026.2.26", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d19359630b10..a5554cd4c5e8 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -4,7 +4,6 @@ import { collectWhatsAppStatusIssues, createActionGate, DEFAULT_ACCOUNT_ID, - escapeRegExp, formatPairingApproveHint, getChatChannelMeta, listWhatsAppAccountIds, @@ -14,15 +13,19 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, + normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, - normalizeWhatsAppTarget, readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripPatterns, whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, @@ -112,12 +115,7 @@ export const whatsappPlugin: ChannelPlugin = { }), resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => { const root = cfg.channels?.whatsapp; const normalized = normalizeAccountId(accountId); @@ -142,8 +140,12 @@ export const whatsappPlugin: ChannelPlugin = { }; }, collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + }); if (groupPolicy !== "open") { return []; } @@ -205,18 +207,10 @@ export const whatsappPlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID (group participant id).", + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) { - return []; - } - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, + stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx), }, commands: { enforceOwnerForCommands: true, diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 86295a310ef6..51bcd15bad30 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -71,8 +71,10 @@ vi.mock("openclaw/plugin-sdk", () => ({ readStringParam: vi.fn(), resolveDefaultWhatsAppAccountId: vi.fn(), resolveWhatsAppAccount: vi.fn(), + resolveWhatsAppGroupIntroHint: vi.fn(), resolveWhatsAppGroupRequireMention: vi.fn(), resolveWhatsAppGroupToolPolicy: vi.fn(), + resolveWhatsAppMentionStripPatterns: vi.fn(() => []), applyAccountNameToChannelSection: vi.fn(), })); diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 3be1369d6233..341a8e37d1bb 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index f0edd3e3a764..c6e64ee121a7 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalo", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { "undici": "7.22.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 318220f8c16f..a5fca946ca7a 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -3,7 +3,7 @@ import type { ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk"; -import { jsonResult, readStringParam } from "openclaw/plugin-sdk"; +import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk"; import { listEnabledZaloAccounts } from "./accounts.js"; import { sendMessageZalo } from "./send.js"; @@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsButtons: () => false, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId }) => { if (action === "send") { const to = readStringParam(params, "to", { required: true }); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b7f9fce996d2..34706e168828 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -7,6 +7,7 @@ import type { import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, @@ -15,6 +16,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -55,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { export const zaloDock: ChannelDock = { id: "zalo", capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, @@ -81,7 +84,7 @@ export const zaloPlugin: ChannelPlugin = { meta, onboarding: zaloOnboardingAdapter, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, @@ -142,6 +145,31 @@ export const zaloPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }; }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.zalo !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => + String(entry), + ); + const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`, + ]; + }, }, groups: { resolveRequireMention: () => true, @@ -309,17 +337,7 @@ export const zaloPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectZaloStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index db4fba278143..a38a0a1cbfd9 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({ webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts new file mode 100644 index 000000000000..7acd1997096b --- /dev/null +++ b/extensions/zalo/src/group-access.ts @@ -0,0 +1,48 @@ +import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk"; +import { + evaluateSenderGroupAccess, + isNormalizedSenderAllowed, + resolveOpenProviderRuntimeGroupPolicy, +} from "openclaw/plugin-sdk"; + +const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i; + +export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean { + return isNormalizedSenderAllowed({ + senderId, + allowFrom, + stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE, + }); +} + +export function resolveZaloRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + +export function evaluateZaloGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + groupAllowFrom: string[]; + senderId: string; +}): SenderGroupAccessDecision { + return evaluateSenderGroupAccess({ + providerConfigPresent: params.providerConfigPresent, + configuredGroupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + groupAllowFrom: params.groupAllowFrom, + senderId: params.senderId, + isSenderAllowed: isZaloSenderAllowed, + }); +} diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts new file mode 100644 index 000000000000..2ce0b1be2a23 --- /dev/null +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./monitor.js"; + +describe("zalo group policy access", () => { + it("defaults missing provider config to allowlist", () => { + const resolved = __testing.resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: undefined, + defaultGroupPolicy: "open", + }); + expect(resolved).toEqual({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + }); + }); + + it("blocks all group messages when policy is disabled", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:123"], + senderId: "123", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "disabled", + reason: "disabled", + }); + }); + + it("blocks group messages on allowlist policy with empty allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "attacker", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "empty_allowlist", + }); + }); + + it("blocks sender not in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:victim-user-001"], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "sender_not_allowlisted", + }); + }); + + it("allows sender in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows any sender with wildcard allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["*"], + senderId: "random-user", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows all group senders on open policy", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "open", + defaultGroupPolicy: "allowlist", + groupAllowFrom: [], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "open", + reason: "allowed", + }); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 819a3afe8312..3063e231a216 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,16 +1,14 @@ -import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; +import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { + createScopedPairingAccess, createReplyPrefixOptions, - readJsonBodyWithLimit, - registerWebhookTarget, - rejectNonPostWebhookRequest, - resolveSingleWebhookTarget, resolveSenderCommandAuthorization, + resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, + sendMediaWithLeadingCaption, resolveWebhookPath, - resolveWebhookTargets, - requestBodyErrorToText, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -24,6 +22,16 @@ import { type ZaloMessage, type ZaloUpdate, } from "./api.js"; +import { + evaluateZaloGroupAccess, + isZaloSenderAllowed, + resolveZaloRuntimeGroupPolicy, +} from "./group-access.js"; +import { + handleZaloWebhookRequest as handleZaloWebhookRequestInternal, + registerZaloWebhookTarget as registerZaloWebhookTargetInternal, + type ZaloWebhookTarget, +} from "./monitor.webhook.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { getZaloRuntime } from "./runtime.js"; @@ -52,13 +60,8 @@ export type ZaloMonitorResult = { const ZALO_TEXT_LIMIT = 2000; const DEFAULT_MEDIA_MAX_MB = 5; -const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; -const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; -const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; -const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; type ZaloCoreRuntime = ReturnType; -type WebhookRateLimitState = { count: number; windowStartMs: number }; function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { @@ -66,228 +69,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str } } -function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = senderId.toLowerCase(); - return allowFrom.some((entry) => { - const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, ""); - return normalized === normalizedSenderId; - }); -} - -type WebhookTarget = { - token: string; - account: ResolvedZaloAccount; - config: OpenClawConfig; - runtime: ZaloRuntimeEnv; - core: ZaloCoreRuntime; - secret: string; - path: string; - mediaMaxMb: number; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - fetcher?: ZaloFetch; -}; - -const webhookTargets = new Map(); -const webhookRateLimits = new Map(); -const recentWebhookEvents = new Map(); -const webhookStatusCounters = new Map(); - -function isJsonContentType(value: string | string[] | undefined): boolean { - const first = Array.isArray(value) ? value[0] : value; - if (!first) { - return false; - } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); - return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); -} - -function timingSafeEquals(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left); - const rightBuffer = Buffer.from(right); - - if (leftBuffer.length !== rightBuffer.length) { - const length = Math.max(1, leftBuffer.length, rightBuffer.length); - const paddedLeft = Buffer.alloc(length); - const paddedRight = Buffer.alloc(length); - leftBuffer.copy(paddedLeft); - rightBuffer.copy(paddedRight); - timingSafeEqual(paddedLeft, paddedRight); - return false; - } - - return timingSafeEqual(leftBuffer, rightBuffer); -} - -function isWebhookRateLimited(key: string, nowMs: number): boolean { - const state = webhookRateLimits.get(key); - if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { - webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); - return false; - } - - state.count += 1; - if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { - return true; - } - return false; -} - -function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { - const messageId = update.message?.message_id; - if (!messageId) { - return false; - } - const key = `${update.event_name}:${messageId}`; - const seenAt = recentWebhookEvents.get(key); - recentWebhookEvents.set(key, nowMs); - - if (seenAt && nowMs - seenAt < ZALO_WEBHOOK_REPLAY_WINDOW_MS) { - return true; - } - - if (recentWebhookEvents.size > 5000) { - for (const [eventKey, timestamp] of recentWebhookEvents) { - if (nowMs - timestamp >= ZALO_WEBHOOK_REPLAY_WINDOW_MS) { - recentWebhookEvents.delete(eventKey); - } - } - } - - return false; -} - -function recordWebhookStatus( - runtime: ZaloRuntimeEnv | undefined, - path: string, - statusCode: number, -): void { - if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { - return; - } - const key = `${path}:${statusCode}`; - const next = (webhookStatusCounters.get(key) ?? 0) + 1; - webhookStatusCounters.set(key, next); - if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { - runtime?.log?.( - `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, - ); - } -} - -export function registerZaloWebhookTarget(target: WebhookTarget): () => void { - return registerWebhookTarget(webhookTargets, target).unregister; +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerZaloWebhookTargetInternal(target); } export async function handleZaloWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const resolved = resolveWebhookTargets(req, webhookTargets); - if (!resolved) { - return false; - } - const { targets } = resolved; - - if (rejectNonPostWebhookRequest(req, res)) { - return true; - } - - const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); - const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => - timingSafeEquals(entry.secret, headerToken), - ); - if (matchedTarget.kind === "none") { - res.statusCode = 401; - res.end("unauthorized"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - if (matchedTarget.kind === "ambiguous") { - res.statusCode = 401; - res.end("ambiguous webhook target"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - const target = matchedTarget.target; - const path = req.url ?? ""; - const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; - const nowMs = Date.now(); - - if (isWebhookRateLimited(rateLimitKey, nowMs)) { - res.statusCode = 429; - res.end("Too Many Requests"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (!isJsonContentType(req.headers["content-type"])) { - res.statusCode = 415; - res.end("Unsupported Media Type"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - const body = await readJsonBodyWithLimit(req, { - maxBytes: 1024 * 1024, - timeoutMs: 30_000, - emptyObjectOnEmpty: false, - }); - if (!body.ok) { - res.statusCode = - body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; - const message = - body.code === "PAYLOAD_TOO_LARGE" - ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") - : body.code === "REQUEST_BODY_TIMEOUT" - ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") - : "Bad Request"; - res.end(message); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result } - const raw = body.value; - const record = raw && typeof raw === "object" ? (raw as Record) : null; - const update: ZaloUpdate | undefined = - record && record.ok === true && record.result - ? (record.result as ZaloUpdate) - : ((record as ZaloUpdate | null) ?? undefined); - - if (!update?.event_name) { - res.statusCode = 400; - res.end("Bad Request"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (isReplayEvent(update, nowMs)) { - res.statusCode = 200; - res.end("ok"); - return true; - } - - target.statusSink?.({ lastInboundAt: Date.now() }); - processUpdate( - update, - target.token, - target.account, - target.config, - target.runtime, - target.core, - target.mediaMaxMb, - target.statusSink, - target.fetcher, - ).catch((err) => { - target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => { + await processUpdate( + update, + target.token, + target.account, + target.config, + target.runtime, + target.core as ZaloCoreRuntime, + target.mediaMaxMb, + target.statusSink, + target.fetcher, + ); }); - - res.statusCode = 200; - res.end("ok"); - return true; } function startPollingLoop(params: { @@ -502,6 +304,11 @@ async function processMessageWithPipeline(params: { statusSink, fetcher, } = params; + const pairing = createScopedPairingAccess({ + core, + channel: "zalo", + accountId: account.accountId, + }); const { from, chat, message_id, date } = message; const isGroup = chat.chat_type === "GROUP"; @@ -511,6 +318,42 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); + const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); + const groupAllowFrom = + configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const groupAccess = isGroup + ? evaluateZaloGroupAccess({ + providerConfigPresent: config.channels?.zalo !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + groupAllowFrom, + senderId, + }) + : undefined; + if (groupAccess) { + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied, + providerKey: "zalo", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); + if (!groupAccess.allowed) { + if (groupAccess.reason === "disabled") { + logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`); + } else if (groupAccess.reason === "empty_allowlist") { + logVerbose( + core, + runtime, + `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`, + ); + } else if (groupAccess.reason === "sender_not_allowlisted") { + logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`); + } + return; + } + } + const rawBody = text?.trim() || (mediaPath ? "" : ""); const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ cfg: config, @@ -518,9 +361,10 @@ async function processMessageWithPipeline(params: { isGroup, dmPolicy, configuredAllowFrom: configAllowFrom, + configuredGroupAllowFrom: groupAllowFrom, senderId, - isSenderAllowed, - readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), + isSenderAllowed: isZaloSenderAllowed, + readAllowFromStore: pairing.readAllowFromStore, shouldComputeCommandAuthorized: (body, cfg) => core.channel.commands.shouldComputeCommandAuthorized(body, cfg), resolveCommandAuthorizedFromAuthorizers: (params) => @@ -538,8 +382,7 @@ async function processMessageWithPipeline(params: { if (!allowed) { if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "zalo", + const { code, created } = await pairing.upsertPairingRequest({ id: senderId, meta: { name: senderName ?? undefined }, }); @@ -692,7 +535,7 @@ async function processMessageWithPipeline(params: { } async function deliverZaloReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; token: string; chatId: string; runtime: ZaloRuntimeEnv; @@ -707,24 +550,18 @@ async function deliverZaloReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Zalo photo send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } @@ -833,3 +670,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< return { stop }; } + +export const __testing = { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +}; diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 97162544b6f9..af998bee6744 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -21,113 +21,84 @@ async function withServer(handler: RequestListener, fn: (baseUrl: string) => Pro } } +const DEFAULT_ACCOUNT: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, +}; + +const webhookRequestHandler: RequestListener = async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } +}; + +function registerTarget(params: { + path: string; + secret?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): () => void { + return registerZaloWebhookTarget({ + token: "tok", + account: DEFAULT_ACCOUNT, + config: {} as OpenClawConfig, + runtime: {}, + core: {} as PluginRuntime, + secret: params.secret ?? "secret", + path: params.path, + mediaMaxMb: 5, + statusSink: params.statusSink, + }); +} + describe("handleZaloWebhookRequest", () => { it("returns 400 for non-object payloads", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "null", - }); - - expect(response.status).toBe(400); - expect(await response.text()).toBe("Bad Request"); - }, - ); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "null", + }); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("Bad Request"); + }); } finally { unregister(); } }); it("rejects ambiguous routing when multiple targets match the same secret", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sinkA = vi.fn(); const sinkB = vi.fn(); - const unregisterA = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkA, - }); - const unregisterB = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook", - mediaMaxMb: 5, - statusSink: sinkB, - }); + const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA }); + const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - - expect(response.status).toBe(401); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).not.toHaveBeenCalled(); - }, - ); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); + + expect(response.status).toBe(401); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).not.toHaveBeenCalled(); + }); } finally { unregisterA(); unregisterB(); @@ -135,73 +106,29 @@ describe("handleZaloWebhookRequest", () => { }); it("returns 415 for non-json content-type", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-content-type", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-content-type" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const response = await fetch(`${baseUrl}/hook-content-type`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "text/plain", - }, - body: "{}", - }); - - expect(response.status).toBe(415); - }, - ); + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook-content-type`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "text/plain", + }, + body: "{}", + }); + + expect(response.status).toBe(415); + }); } finally { unregister(); } }); it("deduplicates webhook replay by event_name + message_id", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; const sink = vi.fn(); - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-replay", - mediaMaxMb: 5, - statusSink: sink, - }); + const unregister = registerTarget({ path: "/hook-replay", statusSink: sink }); const payload = { event_name: "message.text.received", @@ -215,91 +142,56 @@ describe("handleZaloWebhookRequest", () => { }; try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - const first = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - const second = await fetch(`${baseUrl}/hook-replay`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(sink).toHaveBeenCalledTimes(1); - }, - ); + await withServer(webhookRequestHandler, async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + const second = await fetch(`${baseUrl}/hook-replay`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(sink).toHaveBeenCalledTimes(1); + }); } finally { unregister(); } }); it("returns 429 when per-path request rate exceeds threshold", async () => { - const core = {} as PluginRuntime; - const account: ResolvedZaloAccount = { - accountId: "default", - enabled: true, - token: "tok", - tokenSource: "config", - config: {}, - }; - const unregister = registerZaloWebhookTarget({ - token: "tok", - account, - config: {} as OpenClawConfig, - runtime: {}, - core, - secret: "secret", - path: "/hook-rate", - mediaMaxMb: 5, - }); + const unregister = registerTarget({ path: "/hook-rate" }); try { - await withServer( - async (req, res) => { - const handled = await handleZaloWebhookRequest(req, res); - if (!handled) { - res.statusCode = 404; - res.end("not found"); - } - }, - async (baseUrl) => { - let saw429 = false; - for (let i = 0; i < 130; i += 1) { - const response = await fetch(`${baseUrl}/hook-rate`, { - method: "POST", - headers: { - "x-bot-api-secret-token": "secret", - "content-type": "application/json", - }, - body: "{}", - }); - if (response.status === 429) { - saw429 = true; - break; - } + await withServer(webhookRequestHandler, async (baseUrl) => { + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const response = await fetch(`${baseUrl}/hook-rate`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: "{}", + }); + if (response.status === 429) { + saw429 = true; + break; } + } - expect(saw429).toBe(true); - }, - ); + expect(saw429).toBe(true); + }); } finally { unregister(); } diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts new file mode 100644 index 000000000000..dd2b0c655850 --- /dev/null +++ b/extensions/zalo/src/monitor.webhook.ts @@ -0,0 +1,219 @@ +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + createDedupeCache, + readJsonBodyWithLimit, + registerWebhookTarget, + rejectNonPostWebhookRequest, + requestBodyErrorToText, + resolveSingleWebhookTarget, + resolveWebhookTargets, +} from "openclaw/plugin-sdk"; +import type { ResolvedZaloAccount } from "./accounts.js"; +import type { ZaloFetch, ZaloUpdate } from "./api.js"; +import type { ZaloRuntimeEnv } from "./monitor.js"; + +type WebhookRateLimitState = { count: number; windowStartMs: number }; + +const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; +const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; +const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; +const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; + +export type ZaloWebhookTarget = { + token: string; + account: ResolvedZaloAccount; + config: OpenClawConfig; + runtime: ZaloRuntimeEnv; + core: unknown; + secret: string; + path: string; + mediaMaxMb: number; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + fetcher?: ZaloFetch; +}; + +export type ZaloWebhookProcessUpdate = (params: { + update: ZaloUpdate; + target: ZaloWebhookTarget; +}) => Promise; + +const webhookTargets = new Map(); +const webhookRateLimits = new Map(); +const recentWebhookEvents = createDedupeCache({ + ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, + maxSize: 5000, +}); +const webhookStatusCounters = new Map(); + +function isJsonContentType(value: string | string[] | undefined): boolean { + const first = Array.isArray(value) ? value[0] : value; + if (!first) { + return false; + } + const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); +} + +function timingSafeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + const length = Math.max(1, leftBuffer.length, rightBuffer.length); + const paddedLeft = Buffer.alloc(length); + const paddedRight = Buffer.alloc(length); + leftBuffer.copy(paddedLeft); + rightBuffer.copy(paddedRight); + timingSafeEqual(paddedLeft, paddedRight); + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function isWebhookRateLimited(key: string, nowMs: number): boolean { + const state = webhookRateLimits.get(key); + if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { + webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); + return false; + } + + state.count += 1; + if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { + return true; + } + return false; +} + +function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { + const messageId = update.message?.message_id; + if (!messageId) { + return false; + } + const key = `${update.event_name}:${messageId}`; + return recentWebhookEvents.check(key, nowMs); +} + +function recordWebhookStatus( + runtime: ZaloRuntimeEnv | undefined, + path: string, + statusCode: number, +): void { + if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { + return; + } + const key = `${path}:${statusCode}`; + const next = (webhookStatusCounters.get(key) ?? 0) + 1; + webhookStatusCounters.set(key, next); + if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { + runtime?.log?.( + `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, + ); + } +} + +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerWebhookTarget(webhookTargets, target).unregister; +} + +export async function handleZaloWebhookRequest( + req: IncomingMessage, + res: ServerResponse, + processUpdate: ZaloWebhookProcessUpdate, +): Promise { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { + return false; + } + const { targets } = resolved; + + if (rejectNonPostWebhookRequest(req, res)) { + return true; + } + + const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); + const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => + timingSafeEquals(entry.secret, headerToken), + ); + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + const target = matchedTarget.target; + const path = req.url ?? ""; + const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; + const nowMs = Date.now(); + + if (isWebhookRateLimited(rateLimitKey, nowMs)) { + res.statusCode = 429; + res.end("Too Many Requests"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (!isJsonContentType(req.headers["content-type"])) { + res.statusCode = 415; + res.end("Unsupported Media Type"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + const body = await readJsonBodyWithLimit(req, { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + emptyObjectOnEmpty: false, + }); + if (!body.ok) { + res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + const message = + body.code === "PAYLOAD_TOO_LARGE" + ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") + : body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : "Bad Request"; + res.end(message); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }. + const raw = body.value; + const record = raw && typeof raw === "object" ? (raw as Record) : null; + const update: ZaloUpdate | undefined = + record && record.ok === true && record.result + ? (record.result as ZaloUpdate) + : ((record as ZaloUpdate | null) ?? undefined); + + if (!update?.event_name) { + res.statusCode = 400; + res.end("Bad Request"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (isReplayEvent(update, nowMs)) { + res.statusCode = 200; + res.end("ok"); + return true; + } + + target.statusSink?.({ lastInboundAt: Date.now() }); + processUpdate({ update, target }).catch((err) => { + target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + }); + + res.statusCode = 200; + res.end("ok"); + return true; +} diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index bcc43138f976..c17ea0cfc617 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -17,6 +17,10 @@ export type ZaloAccountConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; /** Allowlist for DM senders (Zalo user IDs). */ allowFrom?: Array; + /** Group-message access policy. */ + groupPolicy?: "open" | "allowlist" | "disabled"; + /** Allowlist for group senders (falls back to allowFrom when unset). */ + groupAllowFrom?: Array; /** Max inbound media size in MB. */ mediaMaxMb?: number; /** Proxy URL for API requests. */ diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 4e03fa2d3734..2a59860c1b16 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026.2.26 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index c779e291159c..feb0ce9cfc46 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.22", + "version": "2026.2.26", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { "@sinclair/typebox": "0.34.48" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index c55a76a147d5..c6aee6adcc86 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,10 +1,21 @@ import type { ChildProcess } from "node:child_process"; -import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { + MarkdownTableMode, + OpenClawConfig, + OutboundReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk"; import { + createScopedPairingAccess, createReplyPrefixOptions, + resolveOutboundMediaUrls, mergeAllowlist, + resolveOpenProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, + sendMediaWithLeadingCaption, summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; @@ -167,6 +178,11 @@ async function processMessage( statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, ): Promise { const { threadId, content, timestamp, metadata } = message; + const pairing = createScopedPairingAccess({ + core, + channel: "zalouser", + accountId: account.accountId, + }); if (!content?.trim()) { return; } @@ -177,8 +193,18 @@ async function processMessage( const groupName = metadata?.threadName ?? ""; const chatId = threadId; - const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.zalouser !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "zalouser", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { @@ -205,7 +231,7 @@ async function processMessage( configuredAllowFrom: configAllowFrom, senderId, isSenderAllowed, - readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"), + readAllowFromStore: pairing.readAllowFromStore, shouldComputeCommandAuthorized: (body, cfg) => core.channel.commands.shouldComputeCommandAuthorized(body, cfg), resolveCommandAuthorizedFromAuthorizers: (params) => @@ -223,8 +249,7 @@ async function processMessage( if (!allowed) { if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "zalouser", + const { code, created } = await pairing.upsertPairingRequest({ id: senderId, meta: { name: senderName || undefined }, }); @@ -379,7 +404,7 @@ async function processMessage( } async function deliverZalouserReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; profile: string; chatId: string; isGroup: boolean; @@ -395,29 +420,23 @@ async function deliverZalouserReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - logVerbose(core, runtime, `Sending media to ${chatId}`); - await sendMessageZalouser(chatId, caption ?? "", { - profile, - mediaUrl, - isGroup, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser media send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + logVerbose(core, runtime, `Sending media to ${chatId}`); + await sendMessageZalouser(chatId, caption ?? "", { + profile, + mediaUrl, + isGroup, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error(`Zalouser media send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 03750e1101ec..c623349e7c83 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -23,6 +23,45 @@ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from ". const channel = "zalouser" as const; +function setZalouserAccountScopedConfig( + cfg: OpenClawConfig, + accountId: string, + defaultPatch: Record, + accountPatch: Record = defaultPatch, +): OpenClawConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + ...defaultPatch, + }, + }, + } as OpenClawConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + accounts: { + ...cfg.channels?.zalouser?.accounts, + [accountId]: { + ...cfg.channels?.zalouser?.accounts?.[accountId], + enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, + ...accountPatch, + }, + }, + }, + }, + } as OpenClawConfig; +} + function setZalouserDmPolicy( cfg: OpenClawConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled", @@ -123,40 +162,10 @@ async function promptZalouserAllowFrom(params: { continue; } const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - } as OpenClawConfig; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom: unique, + }); } } @@ -165,37 +174,9 @@ function setZalouserGroupPolicy( accountId: string, groupPolicy: "open" | "allowlist" | "disabled", ): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groupPolicy, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - groupPolicy, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); } function setZalouserGroupAllowlist( @@ -204,37 +185,9 @@ function setZalouserGroupAllowlist( groupKeys: string[], ): OpenClawConfig { const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - groups, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...cfg.channels?.zalouser?.accounts, - [accountId]: { - ...cfg.channels?.zalouser?.accounts?.[accountId], - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - groups, - }, - }, - }, - }, - } as OpenClawConfig; + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); } async function resolveZalouserGroups(params: { @@ -403,38 +356,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { } // Enable the channel - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - profile: account.profile !== "default" ? account.profile : undefined, - }, - }, - } as OpenClawConfig; - } else { - next = { - ...next, - channels: { - ...next.channels, - zalouser: { - ...next.channels?.zalouser, - enabled: true, - accounts: { - ...next.channels?.zalouser?.accounts, - [accountId]: { - ...next.channels?.zalouser?.accounts?.[accountId], - enabled: true, - profile: account.profile, - }, - }, - }, - }, - } as OpenClawConfig; - } + next = setZalouserAccountScopedConfig( + next, + accountId, + { profile: account.profile !== "default" ? account.profile : undefined }, + { profile: account.profile, enabled: true }, + ); if (forceAllowFrom) { next = await promptZalouserAllowFrom({ @@ -447,7 +374,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { const accessConfig = await promptChannelAccessConfig({ prompter, label: "Zalo groups", - currentPolicy: account.config.groupPolicy ?? "open", + currentPolicy: account.config.groupPolicy ?? "allowlist", currentEntries: Object.keys(account.config.groups ?? {}), placeholder: "Family, Work, 123456789", updatePrompt: Boolean(account.config.groups), diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts new file mode 100644 index 000000000000..abca9fd50ed2 --- /dev/null +++ b/extensions/zalouser/src/send.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + sendImageZalouser, + sendLinkZalouser, + sendMessageZalouser, + type ZalouserSendResult, +} from "./send.js"; +import { runZca } from "./zca.js"; + +vi.mock("./zca.js", () => ({ + runZca: vi.fn(), +})); + +const mockRunZca = vi.mocked(runZca); +const originalZcaProfile = process.env.ZCA_PROFILE; + +function okResult(stdout = "message_id: msg-1") { + return { + ok: true, + stdout, + stderr: "", + exitCode: 0, + }; +} + +function failResult(stderr = "") { + return { + ok: false, + stdout: "", + stderr, + exitCode: 1, + }; +} + +describe("zalouser send helpers", () => { + beforeEach(() => { + mockRunZca.mockReset(); + delete process.env.ZCA_PROFILE; + }); + + afterEach(() => { + if (originalZcaProfile) { + process.env.ZCA_PROFILE = originalZcaProfile; + return; + } + delete process.env.ZCA_PROFILE; + }); + + it("returns validation error when thread id is missing", async () => { + const result = await sendMessageZalouser("", "hello"); + expect(result).toEqual({ + ok: false, + error: "No threadId provided", + } satisfies ZalouserSendResult); + expect(mockRunZca).not.toHaveBeenCalled(); + }); + + it("builds text send command with truncation and group flag", async () => { + mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123")); + + const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), { + profile: "profile-a", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], { + profile: "profile-a", + }); + expect(result).toEqual({ ok: true, messageId: "mid-123" }); + }); + + it("routes media sends from sendMessage and keeps text as caption", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-2", "media caption", { + profile: "profile-b", + mediaUrl: "https://cdn.example.com/video.mp4", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "video", + "thread-2", + "-u", + "https://cdn.example.com/video.mp4", + "-m", + "media caption", + "-g", + ], + { profile: "profile-b" }, + ); + }); + + it("maps audio media to voice command", async () => { + mockRunZca.mockResolvedValueOnce(okResult()); + + await sendMessageZalouser("thread-3", "", { + profile: "profile-c", + mediaUrl: "https://cdn.example.com/clip.mp3", + }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"], + { profile: "profile-c" }, + ); + }); + + it("builds image command with caption and returns fallback error", async () => { + mockRunZca.mockResolvedValueOnce(failResult("")); + + const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", { + profile: "profile-d", + caption: "caption text", + isGroup: true, + }); + + expect(mockRunZca).toHaveBeenCalledWith( + [ + "msg", + "image", + "thread-4", + "-u", + "https://cdn.example.com/img.png", + "-m", + "caption text", + "-g", + ], + { profile: "profile-d" }, + ); + expect(result).toEqual({ ok: false, error: "Failed to send image" }); + }); + + it("uses env profile fallback and builds link command", async () => { + process.env.ZCA_PROFILE = "env-profile"; + mockRunZca.mockResolvedValueOnce(okResult("abc123")); + + const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true }); + + expect(mockRunZca).toHaveBeenCalledWith( + ["msg", "link", "thread-5", "https://openclaw.ai", "-g"], + { profile: "env-profile" }, + ); + expect(result).toEqual({ ok: true, messageId: "abc123" }); + }); + + it("returns caught command errors", async () => { + mockRunZca.mockRejectedValueOnce(new Error("zca unavailable")); + + await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({ + ok: false, + error: "zca unavailable", + }); + }); +}); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 0674b88e25ac..1a3c3d3ea664 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -13,12 +13,41 @@ export type ZalouserSendResult = { error?: string; }; +function resolveProfile(options: ZalouserSendOptions): string { + return options.profile || process.env.ZCA_PROFILE || "default"; +} + +function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void { + if (options.caption) { + args.push("-m", options.caption.slice(0, 2000)); + } + if (options.isGroup) { + args.push("-g"); + } +} + +async function runSendCommand( + args: string[], + profile: string, + fallbackError: string, +): Promise { + try { + const result = await runZca(args, { profile }); + if (result.ok) { + return { ok: true, messageId: extractMessageId(result.stdout) }; + } + return { ok: false, error: result.stderr || fallbackError }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export async function sendMessageZalouser( threadId: string, text: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -38,17 +67,7 @@ export async function sendMessageZalouser( args.push("-g"); } - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || "Failed to send message" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send message"); } async function sendMediaZalouser( @@ -56,7 +75,7 @@ async function sendMediaZalouser( mediaUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); if (!threadId?.trim()) { return { ok: false, error: "No threadId provided" }; @@ -78,24 +97,8 @@ async function sendMediaZalouser( } const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - - return { ok: false, error: result.stderr || `Failed to send ${command}` }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, `Failed to send ${command}`); } export async function sendImageZalouser( @@ -103,24 +106,10 @@ export async function sendImageZalouser( imageUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()]; - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } - - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send image" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + appendCaptionAndGroupFlags(args, options); + return runSendCommand(args, profile, "Failed to send image"); } export async function sendLinkZalouser( @@ -128,21 +117,13 @@ export async function sendLinkZalouser( url: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = options.profile || process.env.ZCA_PROFILE || "default"; + const profile = resolveProfile(options); const args = ["msg", "link", threadId.trim(), url.trim()]; if (options.isGroup) { args.push("-g"); } - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || "Failed to send link" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + return runSendCommand(args, profile, "Failed to send link"); } function extractMessageId(stdout: string): string | undefined { diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e6557cb0e79f..8be1649bae5a 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -68,35 +68,30 @@ export type ListenOptions = CommonOptions & { prefix?: string; }; -export type ZalouserAccountConfig = { +type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; + +type ZalouserGroupConfig = { + allow?: boolean; enabled?: boolean; - name?: string; - profile?: string; - dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; - allowFrom?: Array; - groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; - messagePrefix?: string; - responsePrefix?: string; + tools?: ZalouserToolConfig; }; -export type ZalouserConfig = { +type ZalouserSharedConfig = { enabled?: boolean; name?: string; profile?: string; - defaultAccount?: string; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; groupPolicy?: "open" | "allowlist" | "disabled"; - groups?: Record< - string, - { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } } - >; + groups?: Record; messagePrefix?: string; responsePrefix?: string; +}; + +export type ZalouserAccountConfig = ZalouserSharedConfig; + +export type ZalouserConfig = ZalouserSharedConfig & { + defaultAccount?: string; accounts?: Record; }; diff --git a/package.json b/package.json index 292381102292..a59e539105a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.22", + "version": "2026.2.26-beta.1", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", @@ -54,8 +54,9 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", + "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", "deadcode:knip": "pnpm dlx knip --no-progress", @@ -83,16 +84,24 @@ "gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway", "gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset", "gateway:watch": "node scripts/watch-node.mjs gateway --force", + "gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write", + "ghsa:patch": "node scripts/ghsa-patch.mjs", "ios:build": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'", "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", "lint": "oxlint --type-aware", "lint:all": "pnpm lint && pnpm lint:swift", + "lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs", + "lint:auth:pairing-account-scope": "node scripts/check-pairing-account-scope.mjs", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", + "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", + "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", + "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", @@ -128,7 +137,8 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", - "test:ui": "pnpm --dir ui test", + "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", + "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", "tui": "node scripts/run-node.mjs tui", @@ -139,24 +149,25 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.995.0", + "@aws-sdk/client-bedrock": "^3.998.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", - "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.5", + "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.54.0", - "@mariozechner/pi-ai": "0.54.0", - "@mariozechner/pi-coding-agent": "0.54.0", - "@mariozechner/pi-tui": "0.54.0", + "@mariozechner/pi-agent-core": "0.55.1", + "@mariozechner/pi-ai": "0.55.1", + "@mariozechner/pi-coding-agent": "0.55.1", + "@mariozechner/pi-tui": "0.55.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.14.1", + "@snazzah/davey": "^0.1.9", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", @@ -168,8 +179,9 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.0", - "grammy": "^1.40.0", + "grammy": "^1.40.1", "https-proxy-agent": "^7.0.6", + "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", @@ -177,7 +189,7 @@ "long": "^5.3.2", "markdown-it": "^14.1.1", "node-edge-tts": "^1.2.10", - "opusscript": "^0.0.8", + "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.4.624", "playwright-core": "1.58.2", @@ -197,15 +209,15 @@ "@lit/context": "^1.1.6", "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260221.1", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", - "oxfmt": "0.34.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.2", + "oxfmt": "0.35.0", + "oxlint": "^1.50.0", + "oxlint-tsgolint": "^0.15.0", "signal-utils": "0.21.1", "tsdown": "^0.20.3", "tsx": "^4.21.0", @@ -216,6 +228,9 @@ "@napi-rs/canvas": "^0.1.89", "node-llama-cpp": "3.15.1" }, + "optionalDependencies": { + "@discordjs/opus": "^0.10.0" + }, "engines": { "node": ">=22.12.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d086e082857..076ae2c9a853 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,29 +24,29 @@ importers: specifier: 0.14.1 version: 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.995.0 - version: 3.995.0 + specifier: ^3.998.0 + version: 3.998.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.0.1 version: 1.0.1 - '@discordjs/opus': - specifier: ^0.10.0 - version: 0.10.0 '@discordjs/voice': specifier: ^0.19.0 - version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': specifier: ^2.0.3 - version: 2.0.3(grammy@1.40.0) + version: 2.0.3(grammy@1.40.1) '@grammyjs/transformer-throttler': specifier: ^1.2.1 - version: 1.2.1(grammy@1.40.0) + version: 1.2.1(grammy@1.40.1) '@homebridge/ciao': specifier: ^1.3.5 version: 1.3.5 + '@larksuiteoapi/node-sdk': + specifier: ^1.59.0 + version: 1.59.0 '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 @@ -54,23 +54,23 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.54.0 - version: 0.54.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.54.0 - version: 0.54.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.54.0 - version: 0.54.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.54.0 - version: 0.54.0 + specifier: 0.55.1 + version: 0.55.1 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 '@napi-rs/canvas': specifier: ^0.1.89 - version: 0.1.92 + version: 0.1.95 '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 @@ -80,6 +80,9 @@ importers: '@slack/web-api': specifier: ^7.14.1 version: 7.14.1 + '@snazzah/davey': + specifier: ^0.1.9 + version: 0.1.9 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -114,11 +117,14 @@ importers: specifier: ^21.3.0 version: 21.3.0 grammy: - specifier: ^1.40.0 - version: 1.40.0 + specifier: ^1.40.1 + version: 1.40.1 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -144,8 +150,8 @@ importers: specifier: 3.15.1 version: 3.15.1(typescript@5.9.3) opusscript: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.1.1 + version: 0.1.1 osc-progress: specifier: ^0.3.0 version: 0.3.0 @@ -199,8 +205,8 @@ importers: specifier: ^14.1.2 version: 14.1.2 '@types/node': - specifier: ^25.3.0 - version: 25.3.0 + specifier: ^25.3.1 + version: 25.3.1 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -208,29 +214,29 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260221.1 - version: 7.0.0-dev.20260221.1 + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) lit: specifier: ^3.3.2 version: 3.3.2 oxfmt: - specifier: 0.34.0 - version: 0.34.0 + specifier: 0.35.0 + version: 0.35.0 oxlint: - specifier: ^1.49.0 - version: 1.49.0(oxlint-tsgolint@0.14.2) + specifier: ^1.50.0 + version: 1.50.0(oxlint-tsgolint@0.15.0) oxlint-tsgolint: - specifier: ^0.14.2 - version: 0.14.2 + specifier: ^0.15.0 + version: 0.15.0 signal-utils: specifier: 0.21.1 version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260221.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -239,19 +245,21 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + optionalDependencies: + '@discordjs/opus': + specifier: ^0.10.0 + version: 0.10.0 - extensions/bluebubbles: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/acpx: + dependencies: + acpx: + specifier: ^0.1.13 + version: 0.1.13(zod@4.3.6) - extensions/copilot-proxy: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/bluebubbles: {} + + extensions/copilot-proxy: {} extensions/diagnostics-otel: dependencies: @@ -261,13 +269,13 @@ importers: '@opentelemetry/api-logs': specifier: ^0.212.0 version: 0.212.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': + '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': + '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': @@ -288,16 +296,8 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.39.0 version: 1.39.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/discord: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/discord: {} extensions/feishu: dependencies: @@ -310,50 +310,23 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - - extensions/google-antigravity-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-gemini-cli-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/google-gemini-cli-auth: {} extensions/googlechat: dependencies: google-auth-library: - specifier: ^10.5.0 - version: 10.5.0 - devDependencies: + specifier: ^10.6.1 + version: 10.6.1 openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) - extensions/imessage: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/imessage: {} - extensions/irc: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/irc: {} - extensions/line: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/line: {} extensions/llm-task: {} @@ -376,22 +349,14 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/mattermost: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/mattermost: {} extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -402,18 +367,10 @@ importers: specifier: 0.34.48 version: 0.34.48 openai: - specifier: ^6.22.0 - version: 6.22.0(ws@8.19.0)(zod@4.3.6) - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + specifier: ^6.25.0 + version: 6.25.0(ws@8.19.0)(zod@4.3.6) - extensions/minimax-portal-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/minimax-portal-auth: {} extensions/msteams: dependencies: @@ -423,16 +380,8 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/nextcloud-talk: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/nextcloud-talk: {} extensions/nostr: dependencies: @@ -442,46 +391,26 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/open-prose: {} - extensions/signal: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/signal: {} - extensions/slack: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/slack: {} extensions/synology-chat: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + dependencies: + zod: + specifier: ^4.3.6 + version: 4.3.6 - extensions/telegram: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/telegram: {} extensions/tlon: dependencies: '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/twitch: dependencies: @@ -497,10 +426,6 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/voice-call: dependencies: @@ -513,36 +438,20 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/whatsapp: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/whatsapp: {} extensions/zalo: dependencies: undici: specifier: 7.22.0 version: 7.22.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/zalouser: dependencies: '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. packages/clawdbot: dependencies: @@ -584,17 +493,17 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) vite: specifier: 7.3.1 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@vitest/browser-playwright': specifier: 4.0.18 - version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) playwright: specifier: ^1.58.2 version: 1.58.2 vitest: specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -629,127 +538,111 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.995.0': - resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-bedrock@3.995.0': - resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==} + '@aws-sdk/client-bedrock-runtime@3.998.0': + resolution: {integrity: sha512-orRgpdNmdRLik+en3xDxlGuT5AxQU+GFUTMn97ZdRuPLnAiY7Y6/8VTsod6y97/3NB8xuTZbH9wNXzW97IWNMA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.993.0': - resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} + '@aws-sdk/client-bedrock@3.998.0': + resolution: {integrity: sha512-NeSBIdsJwVtACGHXVoguJOsKhq6oR5Q2B6BUU7LWGqIl1skwPors77aLpOa2240ZFtX3Br/0lJYfxAhB8692KA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.11': - resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} + '@aws-sdk/core@3.973.14': + resolution: {integrity: sha512-iAQ1jIGESTVjoqNNY9VlsE9FnCz+Hc8s+dgurF6WrgFyVIw+uggH+V102RFhwjRv4dLSSLfzjDwvQnLszov7TQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.9': - resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} + '@aws-sdk/credential-provider-env@3.972.12': + resolution: {integrity: sha512-WPtj/iAYHHd+NDM6AZoilZwUz0nMaPxbTPGLA7nhyIYRZN2L8trqfbNvm7g/Jr3gzfKp1LpO6AtBTnrhz9WW2g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.11': - resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} + '@aws-sdk/credential-provider-http@3.972.14': + resolution: {integrity: sha512-umtjCicH2o/Fcc8Fu1562UkDyt6gql4czTYVlUfHfAM8S4QEKggzmtHYYYpPfQcjFj1ajyy68ahYSuF67x4ptQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.9': - resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} + '@aws-sdk/credential-provider-ini@3.972.12': + resolution: {integrity: sha512-qjzgnMl6GIBbVeK74jBqSF07+s6kyeZl5R88qjMs302JlqkxE57jkvflDmZ9I017ffEWqIUa9/M4Hfp28qyu1g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.9': - resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} + '@aws-sdk/credential-provider-login@3.972.12': + resolution: {integrity: sha512-AO57y46PzG24bJzxWLk+FYJG6MzxvXoFXnOKnmKUGV43ub4/FS/4Rz7zCC6ThqUotgqEFd30l5LTAd65RP65pg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.10': - resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} + '@aws-sdk/credential-provider-node@3.972.13': + resolution: {integrity: sha512-ME2sgus+gFRtiudy5Xqj9iT/tj8lHOIGrFgktuO5skJU4EngOvTZ1Hpj8mknrW4FgWXmpWhc88NtEscUuuDpKw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.9': - resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} + '@aws-sdk/credential-provider-process@3.972.12': + resolution: {integrity: sha512-msxrHBpVP5AOIDohNPCINUtL47f7XI1TEru3N13uM3nWUMvIRA1vFa8Tlxbxm1EntPPvLAxRmvE5EbjDjOZkbw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.9': - resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} + '@aws-sdk/credential-provider-sso@3.972.12': + resolution: {integrity: sha512-D5iC5546hJyhobJN0szOT4KVeJQ8z/meZq2B3lEDZFcvHONKw+tzq36DAJUy3qLTueeB2geSxiHXngQlA11eoA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.9': - resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} + '@aws-sdk/credential-provider-web-identity@3.972.12': + resolution: {integrity: sha512-yluBahBVsduoA/zgV0NAXtwwXvQ6tNn95dNA3Hg+vISdiPWA46QY0d9PLO2KpNbjtm+1oGcWxemS4fYTwJ0W1w==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.5': - resolution: {integrity: sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==} + '@aws-sdk/eventstream-handler-node@3.972.8': + resolution: {integrity: sha512-tVrf8X7hKnqv3HyVraUbsQW5mfHlD++S5NSIbfQEx0sCRvIwUbTPDl/lJCxhNmZ2zjgUyBIXIKrWilFWBxzv+w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.3': - resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} + '@aws-sdk/middleware-eventstream@3.972.5': + resolution: {integrity: sha512-j8sFerTrzS9tEJhiW2k+T9hsELE+13D5H+mqMjTRyPSgAOebkiK9d4t8vjbLOXuk7yi5lop40x15MubgcjpLmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + '@aws-sdk/middleware-host-header@3.972.5': + resolution: {integrity: sha512-dVA0m1cEQ2iA6yB19aHvWNeUVTuvTt3AXzT0aiIu2uxk0S7AcmwDCDaRgYa/v+eFHcJVxEnpYTozqA7X62xinw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + '@aws-sdk/middleware-logger@3.972.5': + resolution: {integrity: sha512-03RqplLZjUTkYi0dDPR/bbOLnDLFNdaVvNENgA3XK7Ph1MhEBhUYlgoGfOyRAKApDZ+WG4ykOoA8jI8J04jmFA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + '@aws-sdk/middleware-recursion-detection@3.972.5': + resolution: {integrity: sha512-2QSuuVkpHTe84+mDdnFjHX8rAP3g0yYwLVAhS3lQN1rW5Z/zNsf8/pYQrLjLO4n4sPCsUAkTa0Vrod0lk+o1Tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.11': - resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} + '@aws-sdk/middleware-user-agent@3.972.14': + resolution: {integrity: sha512-PzDz+yRAQuIzd+4ZY3s6/TYRzlNKAn4Gae3E5uLV7NnYHqrZHFoAfKE4beXcu3C51pA2/FQ3X2qOGSYqUoN1WQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.6': - resolution: {integrity: sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==} + '@aws-sdk/middleware-websocket@3.972.9': + resolution: {integrity: sha512-O+FSwU9UvKd+QNuGLHqvmP33kkH4jh8pAgdMo3wbFLf+u30fS9/2gbSSWWtNCcWkSNFyG6RUlKU7jPSLApFfGw==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.993.0': - resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.995.0': - resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==} + '@aws-sdk/nested-clients@3.996.2': + resolution: {integrity: sha512-W+u6EM8WRxOIhAhR2mXMHSaUygqItpTehkgxLwJngXqr9RlAR4t6CtECH7o7QK0ct3oyi5Z8ViDHtPbel+D2Rg==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + '@aws-sdk/region-config-resolver@3.972.5': + resolution: {integrity: sha512-AOitrygDwfTNCLCW7L+GScDy1p49FZ6WutTUFWROouoPetfVNmpL4q8TWD3MhfY/ynhoGhleUQENrBH374EU8w==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.993.0': - resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} + '@aws-sdk/token-providers@3.998.0': + resolution: {integrity: sha512-JFzi44tQnENZQ+1DYcHfoa/wTRKkccz0VsNMow0rvsxZtqUEkeV2pYFbir35mHTyUKju9995ay1MAGxLt1dpRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.995.0': - resolution: {integrity: sha512-lYSadNdZZ513qCKoj/KlJ+PgCycL3n8ZNS37qLVFC0t7TbHzoxvGquu9aD2n9OCERAn43OMhQ7dXjYDYdjAXzA==} + '@aws-sdk/types@3.973.3': + resolution: {integrity: sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + '@aws-sdk/util-endpoints@3.996.2': + resolution: {integrity: sha512-83E6T1CKi0/IozPzqRBKqduW0mS4UQdI3soBH6CG7UgupTADWunqEMOTuPWCs9XGjpJJ4ujj+yu7pn8svhp5yg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.993.0': - resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.995.0': - resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.3': - resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} + '@aws-sdk/util-format-url@3.972.5': + resolution: {integrity: sha512-PccfrPQVOEQSL8xaSvu988ESMlqdH1Qfk3AWPZksCOYPHyzYeUV988E+DBachXNV7tBVTUvK85cZYEZu7JtPxQ==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-browser@3.972.5': + resolution: {integrity: sha512-2ja1WqtuBaEAMgVoHYuWx393DF6ULqdt3OozeO7BosqouYaoU47Adtp9vEF+GImSG/Q8A+dqfwDULTTdMkHGUQ==} - '@aws-sdk/util-user-agent-node@3.972.10': - resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==} + '@aws-sdk/util-user-agent-node@3.972.13': + resolution: {integrity: sha512-PHErmuu+v6iAST48zcsB2cYwDKW45gk6qCp49t1p0NGZ4EaFPr/tA5jl0X/ekDwvWbuT0LTj++fjjdVQAbuh0Q==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -757,8 +650,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.5': - resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} + '@aws-sdk/xml-builder@3.972.7': + resolution: {integrity: sha512-9GF86s6mHuc1TYCbuKatMDWl2PyK3KIkpRaI7ul2/gYZPfaLzKZ+ISHhxzVb9KVeakf75tUQe6CXW2gugSCXNw==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.3': @@ -777,12 +670,12 @@ packages: resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} engines: {node: '>=20.0.0'} - '@azure/msal-common@16.0.4': - resolution: {integrity: sha512-0KZ9/wbUyZN65JLAx5bGNfWjkD0kRMUgM99oSpZFg7wEOb3XcKIiHrFnIpgyc8zZ70fHodyh8JKEOel1oN24Gw==} + '@azure/msal-common@16.1.0': + resolution: {integrity: sha512-uiX0ChrRFbreXlPlDR8LwHKmZpJudDAr124iNWJKJ+b7MJUWXmvVU3idSi/c5lk1FwLVZeMxhQir3BGdV09I+g==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.0.4': - resolution: {integrity: sha512-WbA77m68noCw4qV+1tMm5nodll34JCDF0KmrSrp9LskS0bGbgHt98ZRxq69BQK5mjMqDD5ThHJOrrGSfzPybxw==} + '@azure/msal-node@5.0.5': + resolution: {integrity: sha512-CxUYSZgFiviUC3d8Hc+tT7uxre6QkPEWYEHWXmyEBzaO6tfFY4hs5KbXWU6s4q9Zv1NP/04qiR3mcujYLRuYuw==} engines: {node: '>=20'} '@babel/generator@8.0.0-rc.1': @@ -1088,6 +981,15 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@google/genai@1.43.0': + resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grammyjs/runner@2.0.3': resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} engines: {node: '>=12.20.0 || >=14.13.1'} @@ -1477,22 +1379,40 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.54.0': - resolution: {integrity: sha512-LsPoudpOJLj7JjSpjlAdLM5uA2iy8nP+4nA6Si1ASD3tMqXdjHzNaKNloGSODKJO+3O3yhwPMSbuk78CCnZteQ==} + '@mariozechner/pi-agent-core@0.55.0': + resolution: {integrity: sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==} + engines: {node: '>=20.0.0'} + + '@mariozechner/pi-agent-core@0.55.1': + resolution: {integrity: sha512-t9FAb4ouy8HJSIa8gSRC7j8oeUOb2XDdhvBiHj7FhfpYafj1vRPrvGIEXUV8fPJDCI07vhK9iztP27EPk+yEWw==} + engines: {node: '>=20.0.0'} + + '@mariozechner/pi-ai@0.55.0': + resolution: {integrity: sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@mariozechner/pi-ai@0.55.1': + resolution: {integrity: sha512-JJX1LrVWPUPMExu0f89XR4nMNP37+FNLjEE4cIHq9Hi6xQtOiiEi7OjDFMx58hWsq81xH1CwmQXqGTWBjbXKTw==} engines: {node: '>=20.0.0'} + hasBin: true - '@mariozechner/pi-ai@0.54.0': - resolution: {integrity: sha512-XHhMIbFFHCa4mbiYdttfhVg6r3VmFD5tAiW4tjnmf33FhLUCRd76bGMQRc4kLWXPKCi/U4nqAErvaGiZUY4B8A==} + '@mariozechner/pi-coding-agent@0.55.0': + resolution: {integrity: sha512-neflZvWsbFDph3RG+b3/ItfFtGaQnOFJO+N+fsnIC3BG/FEUu1IK1lcMwrM1FGGSMfJnCv7Q3Zk5MSBiRj4azQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.54.0': - resolution: {integrity: sha512-CO8uLmigLzzep2i5/f05dchyywDYDsqykLxpaMXbwDa/dDzsBRbuWoGQBOAsiGbcCMya6AT5nAggFFo4Aqy/+g==} + '@mariozechner/pi-coding-agent@0.55.1': + resolution: {integrity: sha512-H2M8mbBNyDqhON6+3m4H8CjqJ9taGq/CM3B8dG73+VJJIXFm5SExhU9bdgcw2xh0wWj8yEumsj0of6Tu+F7Ffg==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.54.0': - resolution: {integrity: sha512-bvFlUohdxDvKcFeQM2xsd5twCGKWxVaYSlHCFljIW0KqMC4vU+/Ts4A1i9iDnm6Xe/MlueKvC0V09YeC8fLIHA==} + '@mariozechner/pi-tui@0.55.0': + resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==} + engines: {node: '>=20.0.0'} + + '@mariozechner/pi-tui@0.55.1': + resolution: {integrity: sha512-rnqDUp2fm/ySevC0Ltj/ZFRbEc1kZ1A4qHESejj9hA8NVrb/pX9g82XwTE762JOieEGrRWAtmHLNOm7/e4dJMw==} engines: {node: '>=20.0.0'} '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': @@ -1514,144 +1434,74 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} - '@napi-rs/canvas-android-arm64@0.1.92': - resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@napi-rs/canvas-android-arm64@0.1.94': - resolution: {integrity: sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==} + '@napi-rs/canvas-android-arm64@0.1.95': + resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.92': - resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==} + '@napi-rs/canvas-darwin-arm64@0.1.95': + resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-arm64@0.1.94': - resolution: {integrity: sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@napi-rs/canvas-darwin-x64@0.1.92': - resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@napi-rs/canvas-darwin-x64@0.1.94': - resolution: {integrity: sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==} + '@napi-rs/canvas-darwin-x64@0.1.95': + resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': - resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': - resolution: {integrity: sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': + resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': - resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': - resolution: {integrity: sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-musl@0.1.92': - resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': + resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-musl@0.1.94': - resolution: {integrity: sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==} + '@napi-rs/canvas-linux-arm64-musl@0.1.95': + resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': - resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': + resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': - resolution: {integrity: sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - - '@napi-rs/canvas-linux-x64-gnu@0.1.92': - resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-gnu@0.1.94': - resolution: {integrity: sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-musl@0.1.92': - resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==} + '@napi-rs/canvas-linux-x64-gnu@0.1.95': + resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-musl@0.1.94': - resolution: {integrity: sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==} + '@napi-rs/canvas-linux-x64-musl@0.1.95': + resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': - resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': - resolution: {integrity: sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==} + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': + resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.92': - resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@napi-rs/canvas-win32-x64-msvc@0.1.94': - resolution: {integrity: sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==} + '@napi-rs/canvas-win32-x64-msvc@0.1.95': + resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.92': - resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==} - engines: {node: '>= 10'} - - '@napi-rs/canvas@0.1.94': - resolution: {integrity: sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==} + '@napi-rs/canvas@0.1.95': + resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.1.1': @@ -1782,8 +1632,8 @@ packages: resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} engines: {node: '>= 20'} - '@octokit/endpoint@11.0.2': - resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} engines: {node: '>= 20'} '@octokit/graphql@9.0.3': @@ -1826,8 +1676,8 @@ packages: peerDependencies: '@octokit/core': '>=6' - '@octokit/plugin-retry@8.0.3': - resolution: {integrity: sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==} + '@octokit/plugin-retry@8.1.0': + resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} engines: {node: '>= 20'} peerDependencies: '@octokit/core': '>=7' @@ -1842,8 +1692,8 @@ packages: resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} engines: {node: '>= 20'} - '@octokit/request@10.0.7': - resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} engines: {node: '>= 20'} '@octokit/types@16.0.0': @@ -2028,260 +1878,260 @@ packages: '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} - '@oxfmt/binding-android-arm-eabi@0.34.0': - resolution: {integrity: sha512-sqkqjh/Z38l+duOb1HtVqJTAj1grt2ttkobCopC/72+a4Xxz4xUgZPFyQ4HxrYMvyqO/YA0tvM1QbfOu70Gk1Q==} + '@oxfmt/binding-android-arm-eabi@0.35.0': + resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.34.0': - resolution: {integrity: sha512-1KRCtasHcVcGOMwfOP9d5Bus2NFsN8yAYM5cBwi8LBg5UtXC3C49WHKrlEa8iF1BjOS6CR2qIqiFbGoA0DJQNQ==} + '@oxfmt/binding-android-arm64@0.35.0': + resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.34.0': - resolution: {integrity: sha512-b+Rmw9Bva6e/7PBES2wLO8sEU7Mi0+/Kv+pXSe/Y8i4fWNftZZlGwp8P01eECaUqpXATfSgNxdEKy7+ssVNz7g==} + '@oxfmt/binding-darwin-arm64@0.35.0': + resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.34.0': - resolution: {integrity: sha512-QGjpevWzf1T9COEokZEWt80kPOtthW1zhRbo7x4Qoz646eTTfi6XsHG2uHeDWJmTbgBoJZPMgj2TAEV/ppEZaA==} + '@oxfmt/binding-darwin-x64@0.35.0': + resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.34.0': - resolution: {integrity: sha512-VMSaC02cG75qL59M9M/szEaqq/RsLfgpzQ4nqUu8BUnX1zkiZIW2gTpUv3ZJ6qpWnHxIlAXiRZjQwmcwpvtbcg==} + '@oxfmt/binding-freebsd-x64@0.35.0': + resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': - resolution: {integrity: sha512-Klm367PFJhH6vYK3vdIOxFepSJZHPaBfIuqwxdkOcfSQ4qqc/M8sgK0UTFnJWWTA/IkhMIh1kW6uEqiZ/xtQqg==} + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': - resolution: {integrity: sha512-nqn0QueVXRfbN9m58/E9Zij0Ap8lzayx591eWBYn0sZrGzY1IRv9RYS7J/1YUXbb0Ugedo0a8qIWzUHU9bWQuA==} + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.34.0': - resolution: {integrity: sha512-DDn+dcqW+sMTCEjvLoQvC/VWJjG7h8wcdN/J+g7ZTdf/3/Dx730pQElxPPGsCXPhprb11OsPyMp5FwXjMY3qvA==} + '@oxfmt/binding-linux-arm64-gnu@0.35.0': + resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.34.0': - resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==} + '@oxfmt/binding-linux-arm64-musl@0.35.0': + resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': - resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==} + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': - resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==} + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.34.0': - resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==} + '@oxfmt/binding-linux-riscv64-musl@0.35.0': + resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.34.0': - resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==} + '@oxfmt/binding-linux-s390x-gnu@0.35.0': + resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.34.0': - resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==} + '@oxfmt/binding-linux-x64-gnu@0.35.0': + resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.34.0': - resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==} + '@oxfmt/binding-linux-x64-musl@0.35.0': + resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.34.0': - resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==} + '@oxfmt/binding-openharmony-arm64@0.35.0': + resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.34.0': - resolution: {integrity: sha512-6id8kK0t5hKfbV6LHDzRO21wRTA6ctTlKGTZIsG/mcoir0rssvaYsedUymF4HDj7tbCUlnxCX/qOajKlEuqbIw==} + '@oxfmt/binding-win32-arm64-msvc@0.35.0': + resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.34.0': - resolution: {integrity: sha512-QHaz+w673mlYqn9v/+fuiKZpjkmagleXQ+NygShDv8tdHpRYX2oYhTJwwt9j1ZfVhRgza1EIUW3JmzCXmtPdhQ==} + '@oxfmt/binding-win32-ia32-msvc@0.35.0': + resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.34.0': - resolution: {integrity: sha512-CXKQM/VaF+yuvGru8ktleHLJoBdjBtTFmAsLGePiESiTN0NjCI/PiaiOCfHMJ1HdP1LykvARUwMvgaN3tDhcrg==} + '@oxfmt/binding-win32-x64-msvc@0.35.0': + resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.14.2': - resolution: {integrity: sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==} + '@oxlint-tsgolint/darwin-arm64@0.15.0': + resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.14.2': - resolution: {integrity: sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==} + '@oxlint-tsgolint/darwin-x64@0.15.0': + resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.14.2': - resolution: {integrity: sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==} + '@oxlint-tsgolint/linux-arm64@0.15.0': + resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.14.2': - resolution: {integrity: sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==} + '@oxlint-tsgolint/linux-x64@0.15.0': + resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.14.2': - resolution: {integrity: sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==} + '@oxlint-tsgolint/win32-arm64@0.15.0': + resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.14.2': - resolution: {integrity: sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==} + '@oxlint-tsgolint/win32-x64@0.15.0': + resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.49.0': - resolution: {integrity: sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==} + '@oxlint/binding-android-arm-eabi@1.50.0': + resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.49.0': - resolution: {integrity: sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==} + '@oxlint/binding-android-arm64@1.50.0': + resolution: {integrity: sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.49.0': - resolution: {integrity: sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==} + '@oxlint/binding-darwin-arm64@1.50.0': + resolution: {integrity: sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.49.0': - resolution: {integrity: sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==} + '@oxlint/binding-darwin-x64@1.50.0': + resolution: {integrity: sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.49.0': - resolution: {integrity: sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==} + '@oxlint/binding-freebsd-x64@1.50.0': + resolution: {integrity: sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': - resolution: {integrity: sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==} + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': + resolution: {integrity: sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.49.0': - resolution: {integrity: sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==} + '@oxlint/binding-linux-arm-musleabihf@1.50.0': + resolution: {integrity: sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.49.0': - resolution: {integrity: sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==} + '@oxlint/binding-linux-arm64-gnu@1.50.0': + resolution: {integrity: sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.49.0': - resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==} + '@oxlint/binding-linux-arm64-musl@1.50.0': + resolution: {integrity: sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.49.0': - resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==} + '@oxlint/binding-linux-ppc64-gnu@1.50.0': + resolution: {integrity: sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.49.0': - resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==} + '@oxlint/binding-linux-riscv64-gnu@1.50.0': + resolution: {integrity: sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.49.0': - resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==} + '@oxlint/binding-linux-riscv64-musl@1.50.0': + resolution: {integrity: sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.49.0': - resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==} + '@oxlint/binding-linux-s390x-gnu@1.50.0': + resolution: {integrity: sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.49.0': - resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==} + '@oxlint/binding-linux-x64-gnu@1.50.0': + resolution: {integrity: sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.49.0': - resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==} + '@oxlint/binding-linux-x64-musl@1.50.0': + resolution: {integrity: sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.49.0': - resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==} + '@oxlint/binding-openharmony-arm64@1.50.0': + resolution: {integrity: sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.49.0': - resolution: {integrity: sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==} + '@oxlint/binding-win32-arm64-msvc@1.50.0': + resolution: {integrity: sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.49.0': - resolution: {integrity: sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==} + '@oxlint/binding-win32-ia32-msvc@1.50.0': + resolution: {integrity: sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.49.0': - resolution: {integrity: sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==} + '@oxlint/binding-win32-x64-msvc@1.50.0': + resolution: {integrity: sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2461,128 +2311,128 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.58.0': - resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.58.0': - resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.58.0': - resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.58.0': - resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.58.0': - resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.58.0': - resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': - resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.58.0': - resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.58.0': - resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.58.0': - resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.58.0': - resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.58.0': - resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.58.0': - resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.58.0': - resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.58.0': - resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.58.0': - resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.58.0': - resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.58.0': - resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.58.0': - resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.58.0': - resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.58.0': - resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==} + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.58.0': - resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.58.0': - resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.58.0': - resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.58.0': - resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==} + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] @@ -2630,203 +2480,290 @@ packages: resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + '@smithy/abort-controller@4.2.10': + resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + '@smithy/config-resolver@4.4.9': + resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.2': - resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} + '@smithy/core@3.23.6': + resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + '@smithy/credential-provider-imds@4.2.10': + resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + '@smithy/eventstream-codec@4.2.10': + resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + '@smithy/eventstream-serde-browser@4.2.10': + resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + '@smithy/eventstream-serde-config-resolver@4.3.10': + resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + '@smithy/eventstream-serde-node@4.2.10': + resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + '@smithy/eventstream-serde-universal@4.2.10': + resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + '@smithy/fetch-http-handler@5.3.11': + resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + '@smithy/hash-node@4.2.10': + resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + '@smithy/invalid-dependency@4.2.10': + resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + '@smithy/is-array-buffer@4.2.1': + resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + '@smithy/middleware-content-length@4.2.10': + resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.16': - resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} + '@smithy/middleware-endpoint@4.4.20': + resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.33': - resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} + '@smithy/middleware-retry@4.4.37': + resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + '@smithy/middleware-serde@4.2.11': + resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + '@smithy/middleware-stack@4.2.10': + resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + '@smithy/node-config-provider@4.3.10': + resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + '@smithy/node-http-handler@4.4.12': + resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + '@smithy/property-provider@4.2.10': + resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + '@smithy/protocol-http@5.3.10': + resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + '@smithy/querystring-builder@4.2.10': + resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + '@smithy/querystring-parser@4.2.10': + resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + '@smithy/service-error-classification@4.2.10': + resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + '@smithy/shared-ini-file-loader@4.4.5': + resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + '@smithy/signature-v4@5.3.10': + resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.11.5': - resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} + '@smithy/smithy-client@4.12.0': + resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + '@smithy/url-parser@4.2.10': + resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + '@smithy/util-base64@4.3.1': + resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + '@smithy/util-body-length-browser@4.2.1': + resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + '@smithy/util-body-length-node@4.2.2': + resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + '@smithy/util-buffer-from@4.2.1': + resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + '@smithy/util-config-provider@4.2.1': + resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.32': - resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} + '@smithy/util-defaults-mode-browser@4.3.36': + resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.35': - resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} + '@smithy/util-defaults-mode-node@4.2.39': + resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + '@smithy/util-endpoints@3.3.1': + resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + '@smithy/util-hex-encoding@4.2.1': + resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + '@smithy/util-middleware@4.2.10': + resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + '@smithy/util-retry@4.2.10': + resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + '@smithy/util-stream@4.5.15': + resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + '@smithy/util-uri-escape@4.2.1': + resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + '@smithy/util-utf8@4.2.1': + resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + '@smithy/uuid@1.1.1': + resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.9': + resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@snazzah/davey-android-arm64@0.1.9': + resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@snazzah/davey-darwin-arm64@0.1.9': + resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@snazzah/davey-darwin-x64@0.1.9': + resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@snazzah/davey-freebsd-x64@0.1.9': + resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-arm64-musl@0.1.9': + resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-x64-gnu@0.1.9': + resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-linux-x64-musl@0.1.9': + resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-wasm32-wasi@0.1.9': + resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@snazzah/davey-win32-x64-msvc@0.1.9': + resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@snazzah/davey@0.1.9': + resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} + engines: {node: '>= 10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.18': - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} '@thi.ng/bitstream@2.4.41': resolution: {integrity: sha512-treRzw3+7I1YCuilFtznwT3SGtceS9spUXhyBqeuKNTm4nIfMuvg4fNqx4GgpuS6cGPQNPMUJm0OyzKnSe2Emw==} @@ -2947,14 +2884,14 @@ packages: '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@20.19.34': + resolution: {integrity: sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==} - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@24.10.14': + resolution: {integrity: sha512-OowOUbD1lBCOFIPOZ8xnMIhgqA4sCutMiYOmPHL1PTLt5+y1XA+g2+yC9OOyz8p+deMZqPZLxfMjYIfrKsPeFg==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.3.1': + resolution: {integrity: sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==} '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -2992,43 +2929,46 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-m3ttEpK+eXV7P06RVZZuSuUvNDj8psXODrMJRRQWpTNsk3qITbIdBSgOx2Q/M3tbQ9Mo2IBHt6jUjqOdRW9oZQ==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-3qSsqv7FmM4z09wEpEXdhmgMfiJF/OMOZa41AdgMsXTTRpX2/38hDg2KGhi3fc24M2T3MnLPLTqw6HyTOBaV1Q==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-BNaNe3rox2rpkh5sWcnZZob6sDA/at9KK55/WSRAH4W+9dFReOLFAR9YXhKxrLGZ1QpleuIBahKbV8o037S+pA==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-F8ZCCX2UESHcbxvnkd1Dn5PTnOOgpGddFHYgn4usyWRMzNZLPP+YjyGALZe9zdR/D8L0uraND0Haok+TPq8xYg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-Y4jsvwDq86LXq63UYRLqCAd+nD1r6C2NVaGNR39H+c6D8SgOBkPLJa8quTH0Ir8E5bsR8vTN4E6xHY9jD4J2PA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Up8Z/QNcwce5C4rWnbLNW5w7lRARdyKZcNbB1NMnaswaGOBdeDmdP0wbVsOgJMoDp6vnun+EkvrSft8hWLLhIg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-+/uyIw7vg4FyAnNpsCJHmSOhMiR2m56lqaEo1J5pMAstJmfLTTKQdJ1muIWCDCqc24k2U30IStHOaCqUerp/nQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Iu5rnCmqwGIMUu//BXkl9VQaxAAsqVvFhU4mJoNexNkMxPqVcu9quqYAouY7tN/95WcKzUsPpyRfkThdbNFO/g==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-7agd5FtVLPp+gRMvsecSDmdQ/XM80q/uaQ6+Kahan9uNrCuPJIyMiAtJvCoYYgT1nXX2AjwZk39DH63fRaw/Mg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-WWjIfHCWlcriempYYc/sPJ3HFt6znNZKp60nvDNih0+wmxNqEfT5Yzu5zAY0awIe7XLelFSY+bolkpzMYVWEIQ==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-lXbsy5vDzS//oE0evX+QwZBwpKselXTd8H18lT42CBQo2hL2r0+w9YBguaYXrnGkAoHjDXEfKA2xii8yVZKVUg==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-lmfQO+HdmPMk0dtPoNo8dZereTUYNQuapsAI7nFHCP8F25I8eGKKXY2nD1R8W1hp/LmVtske1pqKFNN6IOCt5g==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-O02pfQlVlRTsBmp0hODs/bOHm2ic2kXZpIchBP5Qm0wKCp1Ytz/7i3SNT1gN47I+KC4axn/AHhFmkWQyIu9kRQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-e4eJyzR9ne0XreqYgQNqfX7SNuaePxggnUtVrLERgBv25QKwdQl72GnSXDhdxZHzrb97YwumiXWMQQJj9h8NCg==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260221.1': - resolution: {integrity: sha512-tEUzcnj6pD+z1vANchRzhpPl+3RMD+xQRvIN//0+qjtP5zyYB5T+MIaAWycpKDwlHP9C13JnQgcgYnC+LlNkrg==} + '@typescript/native-preview@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-mUf1aON+eZLupLorX4214n4W6uWIz/lvNv81ErzjJylD/GyJPEJkvDLmgIK3bbvLpMwTRWdVJLhpLCah5Qe8iA==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3149,6 +3089,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acpx@0.1.13: + resolution: {integrity: sha512-C032VkV3cNa13ubq9YhskTWvDTsciNAQfNHZLW3PIN3atdkrzkV0v2yi6Znp7UZDw+pzgpKUsOrZWl64Lwr+3w==} + engines: {node: '>=18'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3286,9 +3231,25 @@ packages: axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - balanced-match@4.0.3: - resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} - engines: {node: 20 || >=22} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3297,8 +3258,8 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} bcrypt-pbkdf@1.0.2: @@ -3333,9 +3294,12 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3457,6 +3421,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -3649,6 +3617,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3731,6 +3702,9 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3746,6 +3720,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + extsprintf@1.3.0: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} @@ -3756,6 +3735,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -3763,6 +3745,9 @@ packages: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3882,10 +3867,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -3898,6 +3879,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -3924,8 +3909,8 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - google-auth-library@10.5.0: - resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + google-auth-library@10.6.1: + resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} engines: {node: '>=18'} google-logging-utils@1.1.3: @@ -3939,14 +3924,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammy@1.40.0: - resolution: {integrity: sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==} + grammy@1.40.1: + resolution: {integrity: sha512-bTe8SWXD8/Sdt2LGAAAsFGhuxI9RG8zL2gGk3V42A/RxriPqBQqwMGoNSldNK1qIFD2EaVuq7NQM8+ZAmNgHLw==} engines: {node: ^12.20.0 || >=14.13.1} - gtoken@8.0.0: - resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} - engines: {node: '>=18'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4076,8 +4057,12 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipull@3.9.3: - resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + ipull@3.9.5: + resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==} engines: {node: '>=18.0.0'} hasBin: true @@ -4190,6 +4175,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.3: + resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4238,8 +4226,8 @@ packages: lifecycle-utils@2.1.0: resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==} - lifecycle-utils@3.1.0: - resolution: {integrity: sha512-kVvegv+r/icjIo1dkHv1hznVQi4FzEVglJD2IU4w07HzevIyH3BAYsFZzEIbBk/nNZjXHGgclJ5g9rz9QdBCLw==} + lifecycle-utils@3.1.1: + resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==} lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} @@ -4707,8 +4695,8 @@ packages: zod: optional: true - openai@6.22.0: - resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} + openai@6.25.0: + resolution: {integrity: sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -4719,11 +4707,19 @@ packages: zod: optional: true + openclaw@2026.2.24: + resolution: {integrity: sha512-a6zrcS6v5tUWqzsFh5cNtyu5+Tra1UW5yvPtYhRYCKSS/q6lXrLu+dj0ylJPOHRPAho2alZZL1gw1Qd2hAd2sQ==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.15.1 + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} - opusscript@0.0.8: - resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==} + opusscript@0.1.1: + resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} @@ -4733,17 +4729,17 @@ packages: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} - oxfmt@0.34.0: - resolution: {integrity: sha512-t+zTE4XGpzPTK+Zk9gSwcJcFi4pqjl6PwO/ZxPBJiJQ2XCKMucwjPlHxvPHyVKJtkMSyrDGfQ7Ntg/hUr4OgHQ==} + oxfmt@0.35.0: + resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.14.2: - resolution: {integrity: sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==} + oxlint-tsgolint@0.15.0: + resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==} hasBin: true - oxlint@1.49.0: - resolution: {integrity: sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==} + oxlint@1.50.0: + resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4852,6 +4848,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -4966,6 +4965,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5101,8 +5103,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.58.0: - resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5209,8 +5211,8 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 - simple-git@3.31.1: - resolution: {integrity: sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} simple-yenc@1.0.4: resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==} @@ -5222,6 +5224,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + skillflag@0.1.4: + resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==} + engines: {node: '>=18'} + hasBin: true + sleep-promise@9.1.0: resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==} @@ -5321,6 +5328,9 @@ packages: resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} engines: {node: '>=18'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5347,6 +5357,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5366,11 +5380,17 @@ packages: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.9: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5749,6 +5769,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -5782,7 +5805,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -5790,7 +5813,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.3 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5798,7 +5821,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -5807,498 +5830,392 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.3 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.995.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/eventstream-handler-node': 3.972.5 - '@aws-sdk/middleware-eventstream': 3.972.3 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/middleware-websocket': 3.972.6 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-bedrock@3.995.0': + '@aws-sdk/client-bedrock-runtime@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/eventstream-handler-node': 3.972.8 + '@aws-sdk/middleware-eventstream': 3.972.5 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/middleware-websocket': 3.972.9 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/eventstream-serde-config-resolver': 4.3.10 + '@smithy/eventstream-serde-node': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.993.0': + '@aws-sdk/client-bedrock@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.11': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.5 - '@smithy/core': 3.23.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core@3.973.14': + dependencies: + '@aws-sdk/types': 3.973.3 + '@aws-sdk/xml-builder': 3.972.7 + '@smithy/core': 3.23.6 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.9': + '@aws-sdk/credential-provider-env@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@aws-sdk/credential-provider-http@3.972.14': + dependencies: + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-login': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/credential-provider-ini@3.972.12': + dependencies: + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-login': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.9': + '@aws-sdk/credential-provider-login@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.10': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-ini': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/credential-provider-node@3.972.13': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-ini': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/types': 3.973.3 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.9': + '@aws-sdk/credential-provider-process@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.9': + '@aws-sdk/credential-provider-sso@3.972.12': dependencies: - '@aws-sdk/client-sso': 3.993.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/token-providers': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.9': + '@aws-sdk/credential-provider-web-identity@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/eventstream-handler-node@3.972.5': + '@aws-sdk/eventstream-handler-node@3.972.8': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.3': + '@aws-sdk/middleware-eventstream@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.3': + '@aws-sdk/middleware-host-header@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.3': + '@aws-sdk/middleware-logger@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.3': + '@aws-sdk/middleware-recursion-detection@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.3 '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.11': + '@aws-sdk/middleware-user-agent@3.972.14': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@smithy/core': 3.23.2 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@smithy/core': 3.23.6 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-format-url': 3.972.3 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/nested-clients@3.993.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/middleware-websocket@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-format-url': 3.972.5 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/nested-clients@3.995.0': + '@aws-sdk/nested-clients@3.996.2': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.993.0': + '@aws-sdk/region-config-resolver@3.972.5': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/config-resolver': 4.4.9 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.995.0': + '@aws-sdk/token-providers@3.998.0': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.973.1': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.993.0': + '@aws-sdk/types@3.973.3': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.995.0': + '@aws-sdk/util-endpoints@3.996.2': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/types': 3.973.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-endpoints': 3.3.1 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.3': + '@aws-sdk/util-format-url@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/util-user-agent-browser@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.3 + '@smithy/types': 4.13.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.10': + '@aws-sdk/util-user-agent-node@3.972.13': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/types': 3.973.3 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.5': + '@aws-sdk/xml-builder@3.972.7': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 fast-xml-parser: 5.3.6 tslib: 2.8.1 @@ -6324,11 +6241,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-common@16.0.4': {} + '@azure/msal-common@16.1.0': {} - '@azure/msal-node@5.0.4': + '@azure/msal-node@5.0.5': dependencies: - '@azure/msal-common': 16.0.4 + '@azure/msal-common': 16.1.0 jsonwebtoken: 9.0.3 uuid: 8.3.2 @@ -6373,13 +6290,13 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8)': + '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 discord-api-types: 0.38.37 optionalDependencies: '@cloudflare/workers-types': 4.20260120.0 - '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@hono/node-server': 1.19.9(hono@4.11.10) '@types/bun': 1.3.9 '@types/ws': 8.18.1 @@ -6518,6 +6435,7 @@ snapshots: transitivePeerDependencies: - encoding - supports-color + optional: true '@discordjs/opus@0.10.0': dependencies: @@ -6526,12 +6444,13 @@ snapshots: transitivePeerDependencies: - encoding - supports-color + optional: true - '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)': + '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': dependencies: '@types/ws': 8.18.1 discord-api-types: 0.38.40 - prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8) + prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1) tslib: 2.8.1 ws: 8.19.0 transitivePeerDependencies: @@ -6641,7 +6560,18 @@ snapshots: '@google/genai@1.42.0': dependencies: - google-auth-library: 10.5.0 + google-auth-library: 10.6.1 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@1.43.0': + dependencies: + google-auth-library: 10.6.1 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.19.0 @@ -6650,15 +6580,15 @@ snapshots: - supports-color - utf-8-validate - '@grammyjs/runner@2.0.3(grammy@1.40.0)': + '@grammyjs/runner@2.0.3(grammy@1.40.1)': dependencies: abort-controller: 3.0.0 - grammy: 1.40.0 + grammy: 1.40.1 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.40.0)': + '@grammyjs/transformer-throttler@1.2.1(grammy@1.40.1)': dependencies: bottleneck: 2.19.5 - grammy: 1.40.0 + grammy: 1.40.1 '@grammyjs/types@3.24.0': {} @@ -6887,7 +6817,7 @@ snapshots: '@line/bot-sdk@10.6.0': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.14 optionalDependencies: axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: @@ -6984,9 +6914,21 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.54.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-agent-core@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.54.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -6996,10 +6938,10 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.54.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.995.0 + '@aws-sdk/client-bedrock-runtime': 3.998.0 '@google/genai': 1.42.0 '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 @@ -7020,16 +6962,70 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.54.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.55.1(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.998.0 + '@google/genai': 1.43.0 + '@mistralai/mistralai': 1.10.0 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.10.0(ws@8.19.0)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.22.0 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-coding-agent@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/jiti': 2.6.5 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + file-type: 21.3.0 + glob: 13.0.6 + hosted-git-info: 9.0.2 + ignore: 7.0.5 + marked: 15.0.12 + minimatch: 10.2.1 + proper-lockfile: 4.1.2 + yaml: 2.8.2 + optionalDependencies: + '@mariozechner/clipboard': 0.3.2 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-coding-agent@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.54.0(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.54.0(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.54.0 + '@mariozechner/pi-agent-core': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.3 + extract-zip: 2.0.1 file-type: 21.3.0 glob: 13.0.6 hosted-git-info: 9.0.2 @@ -7049,7 +7045,16 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.54.0': + '@mariozechner/pi-tui@0.55.0': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + koffi: 2.15.1 + marked: 15.0.12 + mime-types: 3.0.2 + + '@mariozechner/pi-tui@0.55.1': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -7076,7 +7081,7 @@ snapshots: '@microsoft/agents-hosting@1.3.1': dependencies: '@azure/core-auth': 1.10.1 - '@azure/msal-node': 5.0.4 + '@azure/msal-node': 5.0.5 '@microsoft/agents-activity': 1.3.1 axios: 1.13.5(debug@4.4.3) jsonwebtoken: 9.0.3 @@ -7094,100 +7099,52 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/canvas-android-arm64@0.1.92': - optional: true - - '@napi-rs/canvas-android-arm64@0.1.94': - optional: true - - '@napi-rs/canvas-darwin-arm64@0.1.92': - optional: true - - '@napi-rs/canvas-darwin-arm64@0.1.94': - optional: true - - '@napi-rs/canvas-darwin-x64@0.1.92': - optional: true - - '@napi-rs/canvas-darwin-x64@0.1.94': - optional: true - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': - optional: true - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': - optional: true - - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': - optional: true - - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': - optional: true - - '@napi-rs/canvas-linux-arm64-musl@0.1.92': - optional: true - - '@napi-rs/canvas-linux-arm64-musl@0.1.94': + '@napi-rs/canvas-android-arm64@0.1.95': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': + '@napi-rs/canvas-darwin-arm64@0.1.95': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': + '@napi-rs/canvas-darwin-x64@0.1.95': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.92': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.94': + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.92': + '@napi-rs/canvas-linux-arm64-musl@0.1.95': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.94': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': + '@napi-rs/canvas-linux-x64-gnu@0.1.95': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': + '@napi-rs/canvas-linux-x64-musl@0.1.95': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.92': + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.94': + '@napi-rs/canvas-win32-x64-msvc@0.1.95': optional: true - '@napi-rs/canvas@0.1.92': - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.92 - '@napi-rs/canvas-darwin-arm64': 0.1.92 - '@napi-rs/canvas-darwin-x64': 0.1.92 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.92 - '@napi-rs/canvas-linux-arm64-musl': 0.1.92 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-musl': 0.1.92 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.92 - '@napi-rs/canvas-win32-x64-msvc': 0.1.92 - - '@napi-rs/canvas@0.1.94': + '@napi-rs/canvas@0.1.95': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.94 - '@napi-rs/canvas-darwin-arm64': 0.1.94 - '@napi-rs/canvas-darwin-x64': 0.1.94 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.94 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.94 - '@napi-rs/canvas-linux-arm64-musl': 0.1.94 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-musl': 0.1.94 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.94 - '@napi-rs/canvas-win32-x64-msvc': 0.1.94 - optional: true + '@napi-rs/canvas-android-arm64': 0.1.95 + '@napi-rs/canvas-darwin-arm64': 0.1.95 + '@napi-rs/canvas-darwin-x64': 0.1.95 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.95 + '@napi-rs/canvas-linux-arm64-musl': 0.1.95 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-musl': 0.1.95 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.95 + '@napi-rs/canvas-win32-x64-msvc': 0.1.95 '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -7259,7 +7216,7 @@ snapshots: dependencies: '@octokit/auth-oauth-app': 9.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 toad-cache: 3.7.0 @@ -7270,14 +7227,14 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/auth-oauth-device@8.0.3': dependencies: '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -7285,7 +7242,7 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -7300,20 +7257,20 @@ snapshots: dependencies: '@octokit/auth-token': 6.0.0 '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 before-after-hook: 4.0.0 universal-user-agent: 7.0.3 - '@octokit/endpoint@11.0.2': + '@octokit/endpoint@11.0.3': dependencies: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/graphql@9.0.3': dependencies: - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -7333,7 +7290,7 @@ snapshots: '@octokit/oauth-methods@6.0.2': dependencies: '@octokit/oauth-authorization-url': 8.0.0 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -7355,7 +7312,7 @@ snapshots: '@octokit/core': 7.0.6 '@octokit/types': 16.0.0 - '@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)': + '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': dependencies: '@octokit/core': 7.0.6 '@octokit/request-error': 7.1.0 @@ -7372,12 +7329,13 @@ snapshots: dependencies: '@octokit/types': 16.0.0 - '@octokit/request@10.0.7': + '@octokit/request@10.0.8': dependencies: - '@octokit/endpoint': 11.0.2 + '@octokit/endpoint': 11.0.3 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.3 universal-user-agent: 7.0.3 '@octokit/types@16.0.0': @@ -7630,136 +7588,136 @@ snapshots: '@oxc-project/types@0.112.0': {} - '@oxfmt/binding-android-arm-eabi@0.34.0': + '@oxfmt/binding-android-arm-eabi@0.35.0': optional: true - '@oxfmt/binding-android-arm64@0.34.0': + '@oxfmt/binding-android-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-arm64@0.34.0': + '@oxfmt/binding-darwin-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-x64@0.34.0': + '@oxfmt/binding-darwin-x64@0.35.0': optional: true - '@oxfmt/binding-freebsd-x64@0.34.0': + '@oxfmt/binding-freebsd-x64@0.35.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.34.0': + '@oxfmt/binding-linux-arm64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.34.0': + '@oxfmt/binding-linux-arm64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.34.0': + '@oxfmt/binding-linux-riscv64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.34.0': + '@oxfmt/binding-linux-s390x-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.34.0': + '@oxfmt/binding-linux-x64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.34.0': + '@oxfmt/binding-linux-x64-musl@0.35.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.34.0': + '@oxfmt/binding-openharmony-arm64@0.35.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.34.0': + '@oxfmt/binding-win32-arm64-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.34.0': + '@oxfmt/binding-win32-ia32-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.34.0': + '@oxfmt/binding-win32-x64-msvc@0.35.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.14.2': + '@oxlint-tsgolint/darwin-arm64@0.15.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.14.2': + '@oxlint-tsgolint/darwin-x64@0.15.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.14.2': + '@oxlint-tsgolint/linux-arm64@0.15.0': optional: true - '@oxlint-tsgolint/linux-x64@0.14.2': + '@oxlint-tsgolint/linux-x64@0.15.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.14.2': + '@oxlint-tsgolint/win32-arm64@0.15.0': optional: true - '@oxlint-tsgolint/win32-x64@0.14.2': + '@oxlint-tsgolint/win32-x64@0.15.0': optional: true - '@oxlint/binding-android-arm-eabi@1.49.0': + '@oxlint/binding-android-arm-eabi@1.50.0': optional: true - '@oxlint/binding-android-arm64@1.49.0': + '@oxlint/binding-android-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-arm64@1.49.0': + '@oxlint/binding-darwin-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-x64@1.49.0': + '@oxlint/binding-darwin-x64@1.50.0': optional: true - '@oxlint/binding-freebsd-x64@1.49.0': + '@oxlint/binding-freebsd-x64@1.50.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.49.0': + '@oxlint/binding-linux-arm-musleabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.49.0': + '@oxlint/binding-linux-arm64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.49.0': + '@oxlint/binding-linux-arm64-musl@1.50.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.49.0': + '@oxlint/binding-linux-ppc64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.49.0': + '@oxlint/binding-linux-riscv64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.49.0': + '@oxlint/binding-linux-riscv64-musl@1.50.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.49.0': + '@oxlint/binding-linux-s390x-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.49.0': + '@oxlint/binding-linux-x64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-musl@1.49.0': + '@oxlint/binding-linux-x64-musl@1.50.0': optional: true - '@oxlint/binding-openharmony-arm64@1.49.0': + '@oxlint/binding-openharmony-arm64@1.50.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.49.0': + '@oxlint/binding-win32-arm64-msvc@1.50.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.49.0': + '@oxlint/binding-win32-ia32-msvc@1.50.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.49.0': + '@oxlint/binding-win32-x64-msvc@1.50.0': optional: true '@pinojs/redact@0.4.0': {} @@ -7875,79 +7833,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.58.0': + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.58.0': + '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.58.0': + '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.58.0': + '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.58.0': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.58.0': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.58.0': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.58.0': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.58.0': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.58.0': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.58.0': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.58.0': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.58.0': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.58.0': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.58.0': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.58.0': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.58.0': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.58.0': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-openbsd-x64@4.58.0': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.58.0': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.58.0': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.58.0': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.58.0': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.58.0': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true '@scure/base@2.0.0': {} @@ -7993,14 +7951,14 @@ snapshots: '@slack/logger@4.0.0': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@slack/oauth@3.0.4': dependencies: '@slack/logger': 4.0.0 '@slack/web-api': 7.14.1 '@types/jsonwebtoken': 9.0.10 - '@types/node': 25.3.0 + '@types/node': 25.3.1 jsonwebtoken: 9.0.3 transitivePeerDependencies: - debug @@ -8009,7 +7967,7 @@ snapshots: dependencies: '@slack/logger': 4.0.0 '@slack/web-api': 7.14.1 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.19.0 @@ -8024,7 +7982,7 @@ snapshots: dependencies: '@slack/logger': 4.0.0 '@slack/types': 2.20.0 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/retry': 0.12.0 axios: 1.13.5(debug@4.4.3) eventemitter3: 5.0.4 @@ -8037,226 +7995,226 @@ snapshots: transitivePeerDependencies: - debug - '@smithy/abort-controller@4.2.8': + '@smithy/abort-controller@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/config-resolver@4.4.6': + '@smithy/config-resolver@4.4.9': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/core@3.23.2': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 + '@smithy/core@3.23.6': + dependencies: + '@smithy/middleware-serde': 4.2.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.8': + '@smithy/credential-provider-imds@4.2.10': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.8': + '@smithy/eventstream-codec@4.2.10': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.8': + '@smithy/eventstream-serde-browser@4.2.10': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.8': + '@smithy/eventstream-serde-config-resolver@4.3.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.8': + '@smithy/eventstream-serde-node@4.2.10': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.8': + '@smithy/eventstream-serde-universal@4.2.10': dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.9': + '@smithy/fetch-http-handler@5.3.11': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 tslib: 2.8.1 - '@smithy/hash-node@4.2.8': + '@smithy/hash-node@4.2.10': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.8': + '@smithy/invalid-dependency@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': + '@smithy/is-array-buffer@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.8': + '@smithy/middleware-content-length@4.2.10': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.16': + '@smithy/middleware-endpoint@4.4.20': dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 + '@smithy/core': 3.23.6 + '@smithy/middleware-serde': 4.2.11 + '@smithy/node-config-provider': 4.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.33': + '@smithy/middleware-retry@4.4.37': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 + '@smithy/node-config-provider': 4.3.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/service-error-classification': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/uuid': 1.1.1 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.9': + '@smithy/middleware-serde@4.2.11': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.8': + '@smithy/middleware-stack@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.8': + '@smithy/node-config-provider@4.3.10': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.10': + '@smithy/node-http-handler@4.4.12': dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/abort-controller': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/property-provider@4.2.8': + '@smithy/property-provider@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/protocol-http@5.3.8': + '@smithy/protocol-http@5.3.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.8': + '@smithy/querystring-builder@4.2.10': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.1 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.8': + '@smithy/querystring-parser@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/service-error-classification@4.2.8': + '@smithy/service-error-classification@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 - '@smithy/shared-ini-file-loader@4.4.3': + '@smithy/shared-ini-file-loader@4.4.5': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/signature-v4@5.3.8': + '@smithy/signature-v4@5.3.10': dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/smithy-client@4.11.5': + '@smithy/smithy-client@4.12.0': dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@smithy/core': 3.23.6 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-stack': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@smithy/types@4.12.0': + '@smithy/types@4.13.0': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.8': + '@smithy/url-parser@4.2.10': dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/querystring-parser': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-base64@4.3.0': + '@smithy/util-base64@4.3.1': dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.0': + '@smithy/util-body-length-browser@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.1': + '@smithy/util-body-length-node@4.2.2': dependencies: tslib: 2.8.1 @@ -8265,65 +8223,65 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': + '@smithy/util-buffer-from@4.2.1': dependencies: - '@smithy/is-array-buffer': 4.2.0 + '@smithy/is-array-buffer': 4.2.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': + '@smithy/util-config-provider@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.32': + '@smithy/util-defaults-mode-browser@4.3.36': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.35': + '@smithy/util-defaults-mode-node@4.2.39': dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 + '@smithy/config-resolver': 4.4.9 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': + '@smithy/util-endpoints@3.3.1': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': + '@smithy/util-hex-encoding@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@4.2.8': + '@smithy/util-middleware@4.2.10': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-retry@4.2.8': + '@smithy/util-retry@4.2.10': dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/service-error-classification': 4.2.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.12': + '@smithy/util-stream@4.5.15': dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': + '@smithy/util-uri-escape@4.2.1': dependencies: tslib: 2.8.1 @@ -8332,18 +8290,79 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': + '@smithy/util-utf8@4.2.1': dependencies: - '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-buffer-from': 4.2.1 tslib: 2.8.1 - '@smithy/uuid@1.1.0': + '@smithy/uuid@1.1.1': dependencies: tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.9': + optional: true + + '@snazzah/davey-android-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-x64@0.1.9': + optional: true + + '@snazzah/davey-freebsd-x64@0.1.9': + optional: true + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-musl@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-musl@0.1.9': + optional: true + + '@snazzah/davey-wasm32-wasi@0.1.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-x64-msvc@0.1.9': + optional: true + + '@snazzah/davey@0.1.9': + optionalDependencies: + '@snazzah/davey-android-arm-eabi': 0.1.9 + '@snazzah/davey-android-arm64': 0.1.9 + '@snazzah/davey-darwin-arm64': 0.1.9 + '@snazzah/davey-darwin-x64': 0.1.9 + '@snazzah/davey-freebsd-x64': 0.1.9 + '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 + '@snazzah/davey-linux-arm64-gnu': 0.1.9 + '@snazzah/davey-linux-arm64-musl': 0.1.9 + '@snazzah/davey-linux-x64-gnu': 0.1.9 + '@snazzah/davey-linux-x64-musl': 0.1.9 + '@snazzah/davey-wasm32-wasi': 0.1.9 + '@snazzah/davey-win32-arm64-msvc': 0.1.9 + '@snazzah/davey-win32-ia32-msvc': 0.1.9 + '@snazzah/davey-win32-x64-msvc': 0.1.9 + '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.18': + '@swc/helpers@0.5.19': dependencies: tslib: 2.8.1 @@ -8429,7 +8448,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/bun@1.3.9': dependencies: @@ -8449,7 +8468,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/deep-eql@4.0.2': {} @@ -8457,14 +8476,14 @@ snapshots: '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8489,7 +8508,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/linkify-it@5.0.0': {} @@ -8510,15 +8529,15 @@ snapshots: '@types/node@10.17.60': {} - '@types/node@20.19.33': + '@types/node@20.19.34': dependencies: undici-types: 6.21.0 - '@types/node@24.10.13': + '@types/node@24.10.14': dependencies: undici-types: 7.16.0 - '@types/node@25.3.0': + '@types/node@25.3.1': dependencies: undici-types: 7.18.2 @@ -8531,7 +8550,7 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/tough-cookie': 4.0.5 form-data: 2.5.4 @@ -8540,22 +8559,22 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/send@1.2.1': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/send': 0.17.6 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.3.0 + '@types/node': 25.3.1 '@types/tough-cookie@4.0.5': {} @@ -8563,38 +8582,43 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.3.1 + optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260221.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260221.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260221.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260221.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260221.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260221.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260221.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260221.1': + '@typescript/native-preview@7.0.0-dev.20260225.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260221.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260221.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260221.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260221.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260221.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260221.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260221.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260225.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -8631,29 +8655,29 @@ snapshots: - '@cypress/request' - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 4.0.18 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -8661,7 +8685,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -8673,9 +8697,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/expect@4.0.18': dependencies: @@ -8686,13 +8710,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -8764,7 +8788,8 @@ snapshots: curve25519-js: 0.0.4 protobufjs: 6.8.8 - abbrev@1.1.1: {} + abbrev@1.1.1: + optional: true abort-controller@3.0.0: dependencies: @@ -8786,11 +8811,22 @@ snapshots: acorn@8.16.0: {} + acpx@0.1.13(zod@4.3.6): + dependencies: + '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) + commander: 13.1.0 + skillflag: 0.1.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - zod + agent-base@6.0.2: dependencies: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true agent-base@7.1.4: {} @@ -8825,10 +8861,10 @@ snapshots: apache-arrow@18.1.0: dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.19 '@types/command-line-args': 5.2.3 '@types/command-line-usage': 5.0.4 - '@types/node': 20.19.33 + '@types/node': 20.19.34 command-line-args: 5.2.1 command-line-usage: 7.0.3 flatbuffers: 24.12.23 @@ -8841,6 +8877,7 @@ snapshots: dependencies: delegates: 1.0.0 readable-stream: 3.6.2 + optional: true are-we-there-yet@3.0.1: dependencies: @@ -8923,7 +8960,11 @@ snapshots: transitivePeerDependencies: - debug - balanced-match@4.0.3: {} + b4a@1.8.0: {} + + balanced-match@4.0.4: {} + + bare-events@2.8.2: {} base64-js@1.5.1: {} @@ -8931,7 +8972,7 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.1.0: {} + basic-ftp@5.2.0: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -8982,9 +9023,11 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.2: + brace-expansion@5.0.3: dependencies: - balanced-match: 4.0.3 + balanced-match: 4.0.4 + + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -8992,7 +9035,7 @@ snapshots: bun-types@1.3.9: dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 optional: true bytes@3.1.2: {} @@ -9119,6 +9162,8 @@ snapshots: commander@10.0.1: {} + commander@13.1.0: {} + commander@14.0.3: {} console-control-strings@1.1.0: {} @@ -9262,6 +9307,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} @@ -9346,6 +9395,12 @@ snapshots: eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.3.0: {} express@4.22.1: @@ -9419,18 +9474,34 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + extsprintf@1.3.0: {} fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-uri@3.1.0: {} fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -9520,7 +9591,8 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs.realpath@1.0.0: {} + fs.realpath@1.0.0: + optional: true fsevents@2.3.2: optional: true @@ -9541,6 +9613,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 wide-align: 1.1.5 + optional: true gauge@4.0.4: dependencies: @@ -9572,8 +9645,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -9594,13 +9665,17 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 get-uri@6.0.5: dependencies: - basic-ftp: 5.1.0 + basic-ftp: 5.2.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -9635,15 +9710,15 @@ snapshots: minimatch: 10.2.1 once: 1.4.0 path-is-absolute: 1.0.1 + optional: true - google-auth-library@10.5.0: + google-auth-library@10.6.1: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 gaxios: 7.1.3 gcp-metadata: 8.1.2 google-logging-utils: 1.1.3 - gtoken: 8.0.0 jws: 4.0.1 transitivePeerDependencies: - supports-color @@ -9654,7 +9729,7 @@ snapshots: graceful-fs@4.2.11: {} - grammy@1.40.0: + grammy@1.40.1: dependencies: '@grammyjs/types': 3.24.0 abort-controller: 3.0.0 @@ -9664,13 +9739,6 @@ snapshots: - encoding - supports-color - gtoken@8.0.0: - dependencies: - gaxios: 7.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - has-flag@4.0.0: {} has-own@1.0.1: {} @@ -9764,6 +9832,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -9799,6 +9868,7 @@ snapshots: dependencies: once: 1.4.0 wrappy: 1.0.2 + optional: true inherits@2.0.4: {} @@ -9808,7 +9878,9 @@ snapshots: ipaddr.js@1.9.1: {} - ipull@3.9.3: + ipaddr.js@2.3.0: {} + + ipull@3.9.5: dependencies: '@tinyhttp/content-disposition': 2.2.4 async-retry: 1.3.3 @@ -9828,7 +9900,7 @@ snapshots: sleep-promise: 9.1.0 slice-ansi: 7.1.2 stdout-update: 4.0.1 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 optionalDependencies: '@reflink/reflink': 0.1.19 @@ -9851,7 +9923,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 is-interactive@2.0.0: {} @@ -9923,6 +9995,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.3: {} + json5@2.2.3: {} jsonfile@6.2.0: @@ -9995,7 +10069,7 @@ snapshots: lifecycle-utils@2.1.0: {} - lifecycle-utils@3.1.0: {} + lifecycle-utils@3.1.1: {} lightningcss-android-arm64@1.30.2: optional: true @@ -10159,6 +10233,7 @@ snapshots: make-dir@3.1.0: dependencies: semver: 6.3.1 + optional: true make-dir@4.0.0: dependencies: @@ -10215,7 +10290,7 @@ snapshots: minimatch@10.2.1: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.3 minimist@1.2.8: {} @@ -10322,9 +10397,9 @@ snapshots: filenamify: 6.0.0 fs-extra: 11.3.3 ignore: 7.0.5 - ipull: 3.9.3 + ipull: 3.9.5 is-unicode-supported: 2.1.0 - lifecycle-utils: 3.1.0 + lifecycle-utils: 3.1.1 log-symbols: 7.0.1 nanoid: 5.1.6 node-addon-api: 8.5.0 @@ -10333,10 +10408,10 @@ snapshots: pretty-ms: 9.3.0 proper-lockfile: 4.1.2 semver: 7.7.4 - simple-git: 3.31.1 + simple-git: 3.32.3 slice-ansi: 7.1.2 stdout-update: 4.0.1 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 validate-npm-package-name: 6.0.2 which: 5.0.0 yargs: 17.7.2 @@ -10367,6 +10442,7 @@ snapshots: nopt@5.0.0: dependencies: abbrev: 1.1.1 + optional: true nostr-tools@2.23.1(typescript@5.9.3): dependencies: @@ -10388,6 +10464,7 @@ snapshots: console-control-strings: 1.1.0 gauge: 3.0.2 set-blocking: 2.0.0 + optional: true npmlog@6.0.2: dependencies: @@ -10416,7 +10493,7 @@ snapshots: '@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.6) '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - '@octokit/plugin-retry': 8.0.3(@octokit/core@7.0.6) + '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6) '@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6) '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -10455,17 +10532,94 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openai@6.22.0(ws@8.19.0)(zod@4.3.6): + openai@6.25.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 zod: 4.3.6 + openclaw@2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): + dependencies: + '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.998.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) + '@clack/prompts': 1.0.1 + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@grammyjs/runner': 2.0.3(grammy@1.40.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.1) + '@homebridge/ciao': 1.3.5 + '@larksuiteoapi/node-sdk': 1.59.0 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.95 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.14.1 + '@snazzah/davey': 0.1.9 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.40 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.0 + grammy: 1.40.1 + https-proxy-agent: 7.0.6 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + node-llama-cpp: 3.15.1(typescript@5.9.3) + opusscript: 0.1.1 + osc-progress: 0.3.0 + pdfjs-dist: 5.4.624 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.9 + tslog: 4.10.2 + undici: 7.22.0 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + '@discordjs/opus': 0.10.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - hono + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 optional: true - opusscript@0.0.8: {} + opusscript@0.1.1: {} ora@8.2.0: dependencies: @@ -10477,65 +10631,65 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 osc-progress@0.3.0: {} - oxfmt@0.34.0: + oxfmt@0.35.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.34.0 - '@oxfmt/binding-android-arm64': 0.34.0 - '@oxfmt/binding-darwin-arm64': 0.34.0 - '@oxfmt/binding-darwin-x64': 0.34.0 - '@oxfmt/binding-freebsd-x64': 0.34.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.34.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.34.0 - '@oxfmt/binding-linux-arm64-gnu': 0.34.0 - '@oxfmt/binding-linux-arm64-musl': 0.34.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-musl': 0.34.0 - '@oxfmt/binding-linux-s390x-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-musl': 0.34.0 - '@oxfmt/binding-openharmony-arm64': 0.34.0 - '@oxfmt/binding-win32-arm64-msvc': 0.34.0 - '@oxfmt/binding-win32-ia32-msvc': 0.34.0 - '@oxfmt/binding-win32-x64-msvc': 0.34.0 - - oxlint-tsgolint@0.14.2: + '@oxfmt/binding-android-arm-eabi': 0.35.0 + '@oxfmt/binding-android-arm64': 0.35.0 + '@oxfmt/binding-darwin-arm64': 0.35.0 + '@oxfmt/binding-darwin-x64': 0.35.0 + '@oxfmt/binding-freebsd-x64': 0.35.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 + '@oxfmt/binding-linux-arm64-gnu': 0.35.0 + '@oxfmt/binding-linux-arm64-musl': 0.35.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-musl': 0.35.0 + '@oxfmt/binding-linux-s390x-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-musl': 0.35.0 + '@oxfmt/binding-openharmony-arm64': 0.35.0 + '@oxfmt/binding-win32-arm64-msvc': 0.35.0 + '@oxfmt/binding-win32-ia32-msvc': 0.35.0 + '@oxfmt/binding-win32-x64-msvc': 0.35.0 + + oxlint-tsgolint@0.15.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.14.2 - '@oxlint-tsgolint/darwin-x64': 0.14.2 - '@oxlint-tsgolint/linux-arm64': 0.14.2 - '@oxlint-tsgolint/linux-x64': 0.14.2 - '@oxlint-tsgolint/win32-arm64': 0.14.2 - '@oxlint-tsgolint/win32-x64': 0.14.2 - - oxlint@1.49.0(oxlint-tsgolint@0.14.2): + '@oxlint-tsgolint/darwin-arm64': 0.15.0 + '@oxlint-tsgolint/darwin-x64': 0.15.0 + '@oxlint-tsgolint/linux-arm64': 0.15.0 + '@oxlint-tsgolint/linux-x64': 0.15.0 + '@oxlint-tsgolint/win32-arm64': 0.15.0 + '@oxlint-tsgolint/win32-x64': 0.15.0 + + oxlint@1.50.0(oxlint-tsgolint@0.15.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.49.0 - '@oxlint/binding-android-arm64': 1.49.0 - '@oxlint/binding-darwin-arm64': 1.49.0 - '@oxlint/binding-darwin-x64': 1.49.0 - '@oxlint/binding-freebsd-x64': 1.49.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.49.0 - '@oxlint/binding-linux-arm-musleabihf': 1.49.0 - '@oxlint/binding-linux-arm64-gnu': 1.49.0 - '@oxlint/binding-linux-arm64-musl': 1.49.0 - '@oxlint/binding-linux-ppc64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-musl': 1.49.0 - '@oxlint/binding-linux-s390x-gnu': 1.49.0 - '@oxlint/binding-linux-x64-gnu': 1.49.0 - '@oxlint/binding-linux-x64-musl': 1.49.0 - '@oxlint/binding-openharmony-arm64': 1.49.0 - '@oxlint/binding-win32-arm64-msvc': 1.49.0 - '@oxlint/binding-win32-ia32-msvc': 1.49.0 - '@oxlint/binding-win32-x64-msvc': 1.49.0 - oxlint-tsgolint: 0.14.2 + '@oxlint/binding-android-arm-eabi': 1.50.0 + '@oxlint/binding-android-arm64': 1.50.0 + '@oxlint/binding-darwin-arm64': 1.50.0 + '@oxlint/binding-darwin-x64': 1.50.0 + '@oxlint/binding-freebsd-x64': 1.50.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.50.0 + '@oxlint/binding-linux-arm-musleabihf': 1.50.0 + '@oxlint/binding-linux-arm64-gnu': 1.50.0 + '@oxlint/binding-linux-arm64-musl': 1.50.0 + '@oxlint/binding-linux-ppc64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-musl': 1.50.0 + '@oxlint/binding-linux-s390x-gnu': 1.50.0 + '@oxlint/binding-linux-x64-gnu': 1.50.0 + '@oxlint/binding-linux-x64-musl': 1.50.0 + '@oxlint/binding-openharmony-arm64': 1.50.0 + '@oxlint/binding-win32-arm64-msvc': 1.50.0 + '@oxlint/binding-win32-ia32-msvc': 1.50.0 + '@oxlint/binding-win32-x64-msvc': 1.50.0 + oxlint-tsgolint: 0.15.0 p-finally@1.0.0: {} @@ -10605,7 +10759,8 @@ snapshots: partial-json@0.1.7: {} - path-is-absolute@1.0.1: {} + path-is-absolute@1.0.1: + optional: true path-key@3.1.1: {} @@ -10627,11 +10782,13 @@ snapshots: pdfjs-dist@5.4.624: optionalDependencies: - '@napi-rs/canvas': 0.1.94 + '@napi-rs/canvas': 0.1.95 node-readable-to-web-readable-stream: 0.4.2 peberminta@0.9.0: {} + pend@1.2.0: {} + performance-now@2.1.0: {} picocolors@1.1.1: {} @@ -10692,10 +10849,10 @@ snapshots: dependencies: parse-ms: 4.0.0 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8): + prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): optionalDependencies: '@discordjs/opus': 0.10.0 - opusscript: 0.0.8 + opusscript: 0.1.1 process-nextick-args@2.0.1: {} @@ -10735,7 +10892,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.0 + '@types/node': 25.3.1 long: 5.3.2 protobufjs@8.0.0: @@ -10750,7 +10907,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.0 + '@types/node': 25.3.1 long: 5.3.2 proxy-addr@2.0.7: @@ -10777,6 +10934,11 @@ snapshots: dependencies: punycode: 2.3.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -10879,12 +11041,13 @@ snapshots: rimraf@3.0.2: dependencies: glob: 7.2.3 + optional: true rimraf@5.0.10: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260221.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -10897,7 +11060,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260221.1 + '@typescript/native-preview': 7.0.0-dev.20260225.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -10921,35 +11084,35 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 - rollup@4.58.0: + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.58.0 - '@rollup/rollup-android-arm64': 4.58.0 - '@rollup/rollup-darwin-arm64': 4.58.0 - '@rollup/rollup-darwin-x64': 4.58.0 - '@rollup/rollup-freebsd-arm64': 4.58.0 - '@rollup/rollup-freebsd-x64': 4.58.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.58.0 - '@rollup/rollup-linux-arm-musleabihf': 4.58.0 - '@rollup/rollup-linux-arm64-gnu': 4.58.0 - '@rollup/rollup-linux-arm64-musl': 4.58.0 - '@rollup/rollup-linux-loong64-gnu': 4.58.0 - '@rollup/rollup-linux-loong64-musl': 4.58.0 - '@rollup/rollup-linux-ppc64-gnu': 4.58.0 - '@rollup/rollup-linux-ppc64-musl': 4.58.0 - '@rollup/rollup-linux-riscv64-gnu': 4.58.0 - '@rollup/rollup-linux-riscv64-musl': 4.58.0 - '@rollup/rollup-linux-s390x-gnu': 4.58.0 - '@rollup/rollup-linux-x64-gnu': 4.58.0 - '@rollup/rollup-linux-x64-musl': 4.58.0 - '@rollup/rollup-openbsd-x64': 4.58.0 - '@rollup/rollup-openharmony-arm64': 4.58.0 - '@rollup/rollup-win32-arm64-msvc': 4.58.0 - '@rollup/rollup-win32-ia32-msvc': 4.58.0 - '@rollup/rollup-win32-x64-gnu': 4.58.0 - '@rollup/rollup-win32-x64-msvc': 4.58.0 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 router@2.2.0: @@ -10983,7 +11146,8 @@ snapshots: dependencies: parseley: 0.12.1 - semver@6.3.1: {} + semver@6.3.1: + optional: true semver@7.7.4: {} @@ -11122,7 +11286,7 @@ snapshots: dependencies: signal-polyfill: 0.2.2 - simple-git@3.31.1: + simple-git@3.32.3: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -11141,6 +11305,14 @@ snapshots: sisteransi@1.0.5: {} + skillflag@0.1.4: + dependencies: + '@clack/prompts': 1.0.1 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + sleep-promise@9.1.0: {} slice-ansi@7.1.2: @@ -11226,7 +11398,7 @@ snapshots: ansi-escapes: 6.2.1 ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 stealthy-require@1.1.1: {} @@ -11236,6 +11408,15 @@ snapshots: steno@4.0.2: {} + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -11251,8 +11432,8 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: @@ -11270,6 +11451,10 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} strnum@2.1.2: {} @@ -11287,6 +11472,15 @@ snapshots: array-back: 6.2.2 wordwrapjs: 5.1.1 + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.9: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -11295,6 +11489,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -11345,7 +11545,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260221.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -11356,7 +11556,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260221.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -11466,26 +11666,26 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.58.0 + rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.1)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -11502,12 +11702,12 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.0 - '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@types/node': 25.3.1 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) transitivePeerDependencies: - jiti - less @@ -11599,6 +11799,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yoctocolors@2.1.2: {} zod-to-json-schema@3.25.1(zod@3.25.76): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000000..b69da03be53b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82", "I"] + +[tool.pytest.ini_options] +testpaths = ["skills"] +python_files = ["test_*.py"] diff --git a/scripts/check-channel-agnostic-boundaries.mjs b/scripts/check-channel-agnostic-boundaries.mjs new file mode 100644 index 000000000000..3b63911e86d0 --- /dev/null +++ b/scripts/check-channel-agnostic-boundaries.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const acpCoreProtectedSources = [ + path.join(repoRoot, "src", "acp"), + path.join(repoRoot, "src", "agents", "acp-spawn.ts"), + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), + path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"), +]; + +const channelCoreProtectedSources = [ + path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"), + path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"), +]; +const acpUserFacingTextSources = [ + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), +]; +const systemMarkLiteralGuardSources = [ + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), + path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"), + path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"), + path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"), +]; + +const channelIds = [ + "bluebubbles", + "discord", + "googlechat", + "imessage", + "irc", + "line", + "matrix", + "msteams", + "signal", + "slack", + "telegram", + "web", + "whatsapp", + "zalo", + "zalouser", +]; + +const channelIdSet = new Set(channelIds); +const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`); +const comparisonOperators = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); + +const allowedViolations = new Set([]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(targetPath) { + const stat = await fs.stat(targetPath); + if (stat.isFile()) { + if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { + return []; + } + return [targetPath]; + } + + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(targetPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + files.push(entryPath); + } + return files; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function isChannelsPropertyAccess(node) { + if (ts.isPropertyAccessExpression(node)) { + return node.name.text === "channels"; + } + if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) { + return node.argumentExpression.text === "channels"; + } + return false; +} + +function readStringLiteral(node) { + if (ts.isStringLiteral(node)) { + return node.text; + } + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + return null; +} + +function isChannelLiteralNode(node) { + const text = readStringLiteral(node); + return text ? channelIdSet.has(text) : false; +} + +function matchesChannelModuleSpecifier(specifier) { + return channelSegmentRe.test(specifier.replaceAll("\\", "/")); +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +const userFacingChannelNameRe = + /\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i; +const systemMarkLiteral = "⚙️"; + +function isModuleSpecifierStringNode(node) { + const parent = node.parent; + if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) { + return true; + } + return ( + ts.isCallExpression(parent) && + parent.expression.kind === ts.SyntaxKind.ImportKeyword && + parent.arguments[0] === node + ); +} + +export function findChannelAgnosticBoundaryViolations( + content, + fileName = "source.ts", + options = {}, +) { + const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true; + const checkConfigPaths = options.checkConfigPaths ?? true; + const checkChannelComparisons = options.checkChannelComparisons ?? true; + const checkChannelAssignments = options.checkChannelAssignments ?? true; + const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier; + + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if ( + checkModuleSpecifiers && + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.moduleSpecifier), + reason: `imports channel module "${specifier}"`, + }); + } + } + + if ( + checkModuleSpecifiers && + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.moduleSpecifier), + reason: `re-exports channel module "${specifier}"`, + }); + } + } + + if ( + checkModuleSpecifiers && + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length > 0 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = node.arguments[0].text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.arguments[0]), + reason: `dynamically imports channel module "${specifier}"`, + }); + } + } + + if ( + checkConfigPaths && + ts.isPropertyAccessExpression(node) && + channelIdSet.has(node.name.text) + ) { + if (isChannelsPropertyAccess(node.expression)) { + violations.push({ + line: toLine(sourceFile, node.name), + reason: `references config path "channels.${node.name.text}"`, + }); + } + } + + if ( + checkConfigPaths && + ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + channelIdSet.has(node.argumentExpression.text) + ) { + if (isChannelsPropertyAccess(node.expression)) { + violations.push({ + line: toLine(sourceFile, node.argumentExpression), + reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`, + }); + } + } + + if ( + checkChannelComparisons && + ts.isBinaryExpression(node) && + comparisonOperators.has(node.operatorToken.kind) + ) { + if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) { + const leftText = node.left.getText(sourceFile); + const rightText = node.right.getText(sourceFile); + violations.push({ + line: toLine(sourceFile, node.operatorToken), + reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`, + }); + } + } + + if (checkChannelAssignments && ts.isPropertyAssignment(node)) { + const propName = getPropertyNameText(node.name); + if (propName === "channel" && isChannelLiteralNode(node.initializer)) { + violations.push({ + line: toLine(sourceFile, node.initializer), + reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`, + }); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +export function findChannelCoreReverseDependencyViolations(content, fileName = "source.ts") { + return findChannelAgnosticBoundaryViolations(content, fileName, { + checkModuleSpecifiers: true, + checkConfigPaths: false, + checkChannelComparisons: false, + checkChannelAssignments: false, + moduleSpecifierMatcher: matchesChannelModuleSpecifier, + }); +} + +export function findAcpUserFacingChannelNameViolations(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + const text = readStringLiteral(node); + if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `user-facing text references channel name (${JSON.stringify(text)})`, + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +export function findSystemMarkLiteralViolations(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + const text = readStringLiteral(node); + if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `hardcoded system mark literal (${JSON.stringify(text)})`, + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +const boundaryRuleSets = [ + { + id: "acp-core", + sources: acpCoreProtectedSources, + scan: (content, fileName) => findChannelAgnosticBoundaryViolations(content, fileName), + }, + { + id: "channel-core-reverse-deps", + sources: channelCoreProtectedSources, + scan: (content, fileName) => findChannelCoreReverseDependencyViolations(content, fileName), + }, + { + id: "acp-user-facing-text", + sources: acpUserFacingTextSources, + scan: (content, fileName) => findAcpUserFacingChannelNameViolations(content, fileName), + }, + { + id: "system-mark-literal-usage", + sources: systemMarkLiteralGuardSources, + scan: (content, fileName) => findSystemMarkLiteralViolations(content, fileName), + }, +]; + +export async function main() { + const violations = []; + for (const ruleSet of boundaryRuleSets) { + const files = ( + await Promise.all( + ruleSet.sources.map(async (sourcePath) => { + try { + return await collectTypeScriptFiles(sourcePath); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } + }), + ) + ).flat(); + for (const filePath of files) { + const relativeFile = path.relative(repoRoot, filePath); + if ( + allowedViolations.has(`${ruleSet.id}:${relativeFile}`) || + allowedViolations.has(relativeFile) + ) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const violation of ruleSet.scan(content, relativeFile)) { + violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`); + } + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found channel-specific references in channel-agnostic sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Move channel-specific logic to channel adapters or add a justified allowlist entry.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-no-pairing-store-group-auth.mjs b/scripts/check-no-pairing-store-group-auth.mjs new file mode 100644 index 000000000000..316411c460ee --- /dev/null +++ b/scripts/check-no-pairing-store-group-auth.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; + +const allowedFiles = new Set([ + path.join(repoRoot, "src", "security", "dm-policy-shared.ts"), + path.join(repoRoot, "src", "channels", "allow-from.ts"), + // Config migration/audit logic may intentionally reference store + group fields. + path.join(repoRoot, "src", "security", "fix.ts"), + path.join(repoRoot, "src", "security", "audit-channel.ts"), +]); + +const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i; +const groupNameRe = + /(?:groupAllowFrom|effectiveGroupAllowFrom|groupAllowed|groupAllow|groupAuth|groupSender)/i; +const storeSourceCallNames = new Set([ + "readChannelAllowFromStore", + "readChannelAllowFromStoreSync", + "readStoreAllowFromForDmPolicy", +]); +const allowedResolverCallNames = new Set([ + "resolveEffectiveAllowFromLists", + "resolveDmGroupAccessWithLists", + "resolveMattermostEffectiveAllowFromLists", + "resolveIrcEffectiveAllowlists", +]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function getDeclarationNameText(name) { + if (ts.isIdentifier(name)) { + return name.text; + } + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + return name.getText(); + } + return null; +} + +function containsPairingStoreSource(node) { + let found = false; + const visit = (current) => { + if (found) { + return; + } + if (ts.isIdentifier(current) && storeIdentifierRe.test(current.text)) { + found = true; + return; + } + if (ts.isCallExpression(current)) { + const callName = getCallName(current); + if (callName && storeSourceCallNames.has(callName)) { + found = true; + return; + } + } + ts.forEachChild(current, visit); + }; + visit(node); + return found; +} + +function getCallName(node) { + if (!ts.isCallExpression(node)) { + return null; + } + if (ts.isIdentifier(node.expression)) { + return node.expression.text; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return node.expression.name.text; + } + return null; +} + +function isSuspiciousNormalizeWithStoreCall(node) { + if (!ts.isCallExpression(node)) { + return false; + } + if (!ts.isIdentifier(node.expression) || node.expression.text !== "normalizeAllowFromWithStore") { + return false; + } + const firstArg = node.arguments[0]; + if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) { + return false; + } + let hasStoreProp = false; + let hasGroupAllowProp = false; + for (const property of firstArg.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const name = getPropertyNameText(property.name); + if (!name) { + continue; + } + if (name === "storeAllowFrom" && containsPairingStoreSource(property.initializer)) { + hasStoreProp = true; + } + if (name === "allowFrom" && groupNameRe.test(property.initializer.getText())) { + hasGroupAllowProp = true; + } + } + return hasStoreProp && hasGroupAllowProp; +} + +function findViolations(content, filePath) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer) { + const name = getDeclarationNameText(node.name); + if (name && groupNameRe.test(name) && containsPairingStoreSource(node.initializer)) { + const callName = getCallName(node.initializer); + if (callName && allowedResolverCallNames.has(callName)) { + ts.forEachChild(node, visit); + return; + } + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped variable "${name}" references pairing-store identifiers`, + }); + } + } + + if (ts.isPropertyAssignment(node)) { + const propName = getPropertyNameText(node.name); + if (propName && groupNameRe.test(propName) && containsPairingStoreSource(node.initializer)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped property "${propName}" references pairing-store identifiers`, + }); + } + } + + if (isSuspiciousNormalizeWithStoreCall(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: "group allowlist uses normalizeAllowFromWithStore(...) with pairing-store entries", + }); + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) + ).flat(); + + const violations = []; + for (const filePath of files) { + if (allowedFiles.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + const fileViolations = findViolations(content, filePath); + for (const violation of fileViolations) { + violations.push({ + path: path.relative(repoRoot, filePath), + ...violation, + }); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found pairing-store identifiers referenced in group auth composition:"); + for (const violation of violations) { + console.error(`- ${violation.path}:${violation.line} (${violation.reason})`); + } + console.error( + "Group auth must be composed via shared resolvers (resolveDmGroupAccessWithLists / resolveEffectiveAllowFromLists).", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs new file mode 100644 index 000000000000..af7b56a371fb --- /dev/null +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "infra", "outbound"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "src", "media-understanding"), + path.join(repoRoot, "extensions"), +]; +const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function collectOsTmpdirImports(sourceFile) { + const osModuleSpecifiers = new Set(["node:os", "os"]); + const osNamespaceOrDefault = new Set(); + const namedTmpdir = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + if (!osModuleSpecifiers.has(statement.moduleSpecifier.text)) { + continue; + } + const clause = statement.importClause; + if (clause.name) { + osNamespaceOrDefault.add(clause.name.text); + } + if (!clause.namedBindings) { + continue; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + osNamespaceOrDefault.add(clause.namedBindings.name.text); + continue; + } + for (const element of clause.namedBindings.elements) { + if ((element.propertyName?.text ?? element.name.text) === "tmpdir") { + namedTmpdir.add(element.name.text); + } + } + } + return { osNamespaceOrDefault, namedTmpdir }; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "tmpdir" && + ts.isIdentifier(callee.expression) && + osNamespaceOrDefault.has(callee.expression.text) + ) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + ).flat(); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const line of findMessagingTmpdirCallLines(content, filePath)) { + violations.push(`${path.relative(repoRoot, filePath)}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs new file mode 100644 index 000000000000..56008b3f1d8b --- /dev/null +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "telegram"), + path.join(repoRoot, "src", "discord"), + path.join(repoRoot, "src", "slack"), + path.join(repoRoot, "src", "signal"), + path.join(repoRoot, "src", "imessage"), + path.join(repoRoot, "src", "web"), + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "routing"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "extensions"), +]; + +// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime +// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. +const allowedRawFetchCallsites = new Set([ + "extensions/bluebubbles/src/types.ts:131", + "extensions/feishu/src/streaming-card.ts:31", + "extensions/feishu/src/streaming-card.ts:100", + "extensions/feishu/src/streaming-card.ts:141", + "extensions/feishu/src/streaming-card.ts:197", + "extensions/google-gemini-cli-auth/oauth.ts:372", + "extensions/google-gemini-cli-auth/oauth.ts:408", + "extensions/google-gemini-cli-auth/oauth.ts:447", + "extensions/google-gemini-cli-auth/oauth.ts:507", + "extensions/google-gemini-cli-auth/oauth.ts:575", + "extensions/googlechat/src/api.ts:22", + "extensions/googlechat/src/api.ts:43", + "extensions/googlechat/src/api.ts:63", + "extensions/googlechat/src/api.ts:184", + "extensions/googlechat/src/auth.ts:82", + "extensions/matrix/src/directory-live.ts:41", + "extensions/matrix/src/matrix/client/config.ts:171", + "extensions/mattermost/src/mattermost/client.ts:211", + "extensions/mattermost/src/mattermost/monitor.ts:230", + "extensions/mattermost/src/mattermost/probe.ts:27", + "extensions/minimax-portal-auth/oauth.ts:71", + "extensions/minimax-portal-auth/oauth.ts:112", + "extensions/msteams/src/graph.ts:39", + "extensions/nextcloud-talk/src/room-info.ts:92", + "extensions/nextcloud-talk/src/send.ts:107", + "extensions/nextcloud-talk/src/send.ts:198", + "extensions/qwen-portal-auth/oauth.ts:46", + "extensions/qwen-portal-auth/oauth.ts:80", + "extensions/talk-voice/index.ts:27", + "extensions/thread-ownership/index.ts:105", + "extensions/voice-call/src/providers/plivo.ts:95", + "extensions/voice-call/src/providers/telnyx.ts:61", + "extensions/voice-call/src/providers/tts-openai.ts:111", + "extensions/voice-call/src/providers/twilio/api.ts:23", + "src/channels/telegram/api.ts:8", + "src/discord/send.outbound.ts:347", + "src/discord/voice-message.ts:267", + "src/slack/monitor/media.ts:64", + "src/slack/monitor/media.ts:68", + "src/slack/monitor/media.ts:82", + "src/slack/monitor/media.ts:108", +]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") || + filePath.endsWith(".browser.test.ts") || + filePath.endsWith(".node.test.ts") + ); +} + +async function collectTypeScriptFiles(targetPath) { + const stat = await fs.stat(targetPath); + if (stat.isFile()) { + if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { + return []; + } + return [targetPath]; + } + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(targetPath, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") { + continue; + } + files.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + files.push(entryPath); + } + return files; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +function isRawFetchCall(expression) { + const callee = unwrapExpression(expression); + if (ts.isIdentifier(callee)) { + return callee.text === "fetch"; + } + if (ts.isPropertyAccessExpression(callee)) { + return ( + ts.isIdentifier(callee.expression) && + callee.expression.text === "globalThis" && + callee.name.text === "fetch" + ); + } + return false; +} + +export function findRawFetchCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const lines = []; + const visit = (node) => { + if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) { + const line = + sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; + lines.push(line); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all( + sourceRoots.map(async (sourceRoot) => { + try { + return await collectTypeScriptFiles(sourceRoot); + } catch { + return []; + } + }), + ) + ).flat(); + + const violations = []; + for (const filePath of files) { + const content = await fs.readFile(filePath, "utf8"); + const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/"); + for (const line of findRawFetchCallLines(content, filePath)) { + const callsite = `${relPath}:${line}`; + if (allowedRawFetchCallsites.has(callsite)) { + continue; + } + violations.push(callsite); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found raw fetch() usage in channel/plugin runtime sources outside allowlist:"); + for (const violation of violations.toSorted()) { + console.error(`- ${violation}`); + } + console.error( + "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs new file mode 100644 index 000000000000..930bfe60a612 --- /dev/null +++ b/scripts/check-no-raw-window-open.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const uiSourceDir = path.join(repoRoot, "ui", "src", "ui"); +const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]); + +function isTestFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".browser.test.ts") || + filePath.endsWith(".node.test.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +function asPropertyAccess(expression) { + if (ts.isPropertyAccessExpression(expression)) { + return expression; + } + if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) { + return expression; + } + return null; +} + +function isRawWindowOpenCall(expression) { + const propertyAccess = asPropertyAccess(unwrapExpression(expression)); + if (!propertyAccess || propertyAccess.name.text !== "open") { + return false; + } + + const receiver = unwrapExpression(propertyAccess.expression); + return ( + ts.isIdentifier(receiver) && (receiver.text === "window" || receiver.text === "globalThis") + ); +} + +export function findRawWindowOpenLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) { + const line = + sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; + lines.push(line); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = await collectTypeScriptFiles(uiSourceDir); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + + const content = await fs.readFile(filePath, "utf8"); + for (const line of findRawWindowOpenLines(content, filePath)) { + const relPath = path.relative(repoRoot, filePath); + violations.push(`${relPath}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found raw window.open usage outside safe helper:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead."); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-pairing-account-scope.mjs b/scripts/check-pairing-account-scope.mjs new file mode 100644 index 000000000000..21db11a87a25 --- /dev/null +++ b/scripts/check-pairing-account-scope.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function isUndefinedLikeExpression(node) { + if (ts.isIdentifier(node) && node.text === "undefined") { + return true; + } + return node.kind === ts.SyntaxKind.NullKeyword; +} + +function hasRequiredAccountIdProperty(node) { + if (!ts.isObjectLiteralExpression(node)) { + return false; + } + for (const property of node.properties) { + if (ts.isShorthandPropertyAssignment(property) && property.name.text === "accountId") { + return true; + } + if (!ts.isPropertyAssignment(property)) { + continue; + } + if (getPropertyNameText(property.name) !== "accountId") { + continue; + } + if (isUndefinedLikeExpression(property.initializer)) { + return false; + } + return true; + } + return false; +} + +function findViolations(content, filePath) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const callName = node.expression.text; + if (callName === "readChannelAllowFromStore") { + if (node.arguments.length < 3 || isUndefinedLikeExpression(node.arguments[2])) { + violations.push({ + line: toLine(sourceFile, node), + reason: "readChannelAllowFromStore call must pass explicit accountId as 3rd arg", + }); + } + } else if ( + callName === "readLegacyChannelAllowFromStore" || + callName === "readLegacyChannelAllowFromStoreSync" + ) { + violations.push({ + line: toLine(sourceFile, node), + reason: `${callName} is legacy-only; use account-scoped readChannelAllowFromStore* APIs`, + }); + } else if (callName === "upsertChannelPairingRequest") { + const firstArg = node.arguments[0]; + if (!firstArg || !hasRequiredAccountIdProperty(firstArg)) { + violations.push({ + line: toLine(sourceFile, node), + reason: "upsertChannelPairingRequest call must include accountId in params", + }); + } + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) + ).flat(); + const violations = []; + + for (const filePath of files) { + const content = await fs.readFile(filePath, "utf8"); + const fileViolations = findViolations(content, filePath); + for (const violation of fileViolations) { + violations.push({ + path: path.relative(repoRoot, filePath), + ...violation, + }); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found unscoped pairing-store calls:"); + for (const violation of violations) { + console.error(`- ${violation.path}:${violation.line} (${violation.reason})`); + } + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/dev/discord-acp-plain-language-smoke.ts b/scripts/dev/discord-acp-plain-language-smoke.ts new file mode 100644 index 000000000000..33b8eb0d54f9 --- /dev/null +++ b/scripts/dev/discord-acp-plain-language-smoke.ts @@ -0,0 +1,779 @@ +#!/usr/bin/env bun +// Manual ACP thread smoke for plain-language routing. +// Keep this script available for regression/debug validation. Do not delete. +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type ThreadBindingRecord = { + accountId?: string; + channelId?: string; + threadId?: string; + targetKind?: string; + targetSessionKey?: string; + agentId?: string; + boundBy?: string; + boundAt?: number; +}; + +type ThreadBindingsPayload = { + version?: number; + bindings?: Record; +}; + +type DiscordMessage = { + id: string; + content?: string; + timestamp?: string; + author?: { + id?: string; + username?: string; + bot?: boolean; + }; +}; + +type DiscordUser = { + id: string; + username: string; + bot?: boolean; +}; + +type DriverMode = "token" | "webhook"; + +type Args = { + channelId: string; + driverMode: DriverMode; + driverToken: string; + driverTokenPrefix: string; + botToken: string; + botTokenPrefix: string; + targetAgent: string; + timeoutMs: number; + pollMs: number; + mentionUserId?: string; + instruction?: string; + threadBindingsPath: string; + json: boolean; +}; + +type SuccessResult = { + ok: true; + smokeId: string; + ackToken: string; + sentMessageId: string; + binding: { + threadId: string; + targetSessionKey: string; + targetKind: string; + agentId: string; + boundAt: number; + accountId?: string; + channelId?: string; + }; + ackMessage: { + id: string; + authorId?: string; + authorUsername?: string; + timestamp?: string; + content?: string; + }; +}; + +type FailureResult = { + ok: false; + smokeId: string; + stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected"; + error: string; + diagnostics?: { + parentChannelRecent?: Array<{ + id: string; + author?: string; + bot?: boolean; + content?: string; + }>; + bindingCandidates?: Array<{ + threadId: string; + targetSessionKey: string; + targetKind?: string; + agentId?: string; + boundAt?: number; + }>; + }; +}; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseNumber(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function resolveStateDir(): string { + const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim(); + if (override) { + return override.startsWith("~") + ? path.resolve(process.env.HOME || "", override.slice(1)) + : path.resolve(override); + } + const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || ""; + return path.join(home, ".openclaw"); +} + +function resolveArg(flag: string): string | undefined { + const argv = process.argv.slice(2); + const eq = argv.find((entry) => entry.startsWith(`${flag}=`)); + if (eq) { + return eq.slice(flag.length + 1); + } + const idx = argv.indexOf(flag); + if (idx >= 0 && idx + 1 < argv.length) { + return argv[idx + 1]; + } + return undefined; +} + +function hasFlag(flag: string): boolean { + return process.argv.slice(2).includes(flag); +} + +function usage(): string { + return ( + "Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " + + "--channel [--token | --driver webhook --bot-token ] [options]\n\n" + + "Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" + + "1) OpenClaw spawned an ACP thread binding\n" + + "2) agent replied in that bound thread with the expected ACK token\n\n" + + "Options:\n" + + " --channel Parent Discord channel id (required)\n" + + " --driver Driver transport mode (default: token)\n" + + " --token Driver Discord token (required for driver=token)\n" + + " --token-prefix Auth prefix for --token (default: Bot)\n" + + " --bot-token Bot token for webhook driver mode\n" + + " --bot-token-prefix Auth prefix for --bot-token (default: Bot)\n" + + " --agent Expected ACP agent id (default: codex)\n" + + " --mention Mention this user in the instruction (optional)\n" + + " --instruction Custom instruction template (optional)\n" + + " --timeout-ms Total timeout in ms (default: 240000)\n" + + " --poll-ms Poll interval in ms (default: 1500)\n" + + " --thread-bindings-path

Override thread-bindings json path\n" + + " --json Emit JSON output\n" + + "\n" + + "Environment fallbacks:\n" + + " OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" + + " OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" + + " OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" + + " OPENCLAW_DISCORD_SMOKE_AGENT\n" + + " OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" + + " OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" + + " OPENCLAW_DISCORD_SMOKE_POLL_MS\n" + + " OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH" + ); +} + +function parseArgs(): Args { + const channelId = + resolveArg("--channel") || + process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID || + process.env.CLAWDBOT_DISCORD_SMOKE_CHANNEL_ID || + ""; + const driverModeRaw = + resolveArg("--driver") || + process.env.OPENCLAW_DISCORD_SMOKE_DRIVER || + process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER || + "token"; + const normalizedDriverMode = driverModeRaw.trim().toLowerCase(); + const driverMode: DriverMode = + normalizedDriverMode === "webhook" + ? "webhook" + : normalizedDriverMode === "token" + ? "token" + : "token"; + const driverToken = + resolveArg("--token") || + process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN || + process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER_TOKEN || + ""; + const driverTokenPrefix = + resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot"; + const botToken = + resolveArg("--bot-token") || + process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN || + process.env.CLAWDBOT_DISCORD_SMOKE_BOT_TOKEN || + process.env.DISCORD_BOT_TOKEN || + ""; + const botTokenPrefix = + resolveArg("--bot-token-prefix") || + process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX || + "Bot"; + const targetAgent = + resolveArg("--agent") || + process.env.OPENCLAW_DISCORD_SMOKE_AGENT || + process.env.CLAWDBOT_DISCORD_SMOKE_AGENT || + "codex"; + const mentionUserId = + resolveArg("--mention") || + process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID || + process.env.CLAWDBOT_DISCORD_SMOKE_MENTION_USER_ID || + undefined; + const instruction = + resolveArg("--instruction") || + process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION || + process.env.CLAWDBOT_DISCORD_SMOKE_INSTRUCTION || + undefined; + const timeoutMs = parseNumber( + resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS, + 240_000, + ); + const pollMs = parseNumber( + resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS, + 1_500, + ); + const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json"); + const threadBindingsPath = + resolveArg("--thread-bindings-path") || + process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH || + defaultBindingsPath; + const json = hasFlag("--json"); + + if (!channelId) { + throw new Error(usage()); + } + if (driverMode === "token" && !driverToken) { + throw new Error(usage()); + } + if (driverMode === "webhook" && !botToken) { + throw new Error(usage()); + } + + return { + channelId, + driverMode, + driverToken, + driverTokenPrefix, + botToken, + botTokenPrefix, + targetAgent, + timeoutMs, + pollMs, + mentionUserId, + instruction, + threadBindingsPath, + json, + }; +} + +function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string { + const token = params.token.trim(); + if (!token) { + throw new Error("Missing Discord driver token."); + } + if (token.includes(" ")) { + return token; + } + return `${params.tokenPrefix.trim() || "Bot"} ${token}`; +} + +async function discordApi(params: { + method: "GET" | "POST"; + path: string; + authHeader: string; + body?: unknown; + retries?: number; +}): Promise { + const retries = params.retries ?? 6; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const response = await fetch(`${DISCORD_API_BASE}${params.path}`, { + method: params.method, + headers: { + Authorization: params.authHeader, + "Content-Type": "application/json", + }, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + }); + + if (response.status === 429) { + const body = (await response.json().catch(() => ({}))) as { retry_after?: number }; + const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1; + await sleep(Math.ceil(waitSeconds * 1000)); + continue; + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`); +} + +async function discordWebhookApi(params: { + method: "POST" | "DELETE"; + webhookId: string; + webhookToken: string; + body?: unknown; + query?: string; + retries?: number; +}): Promise { + const retries = params.retries ?? 6; + const suffix = params.query ? `?${params.query}` : ""; + const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const response = await fetch(`${DISCORD_API_BASE}${path}`, { + method: params.method, + headers: { + "Content-Type": "application/json", + }, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + }); + + if (response.status === 429) { + const body = (await response.json().catch(() => ({}))) as { retry_after?: number }; + const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1; + await sleep(Math.ceil(waitSeconds * 1000)); + continue; + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`); +} + +async function readThreadBindings(filePath: string): Promise { + const raw = await fs.readFile(filePath, "utf8"); + const payload = JSON.parse(raw) as ThreadBindingsPayload; + const entries = Object.values(payload.bindings ?? {}); + return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey)); +} + +function normalizeBoundAt(record: ThreadBindingRecord): number { + if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) { + return record.boundAt; + } + return 0; +} + +function resolveCandidateBindings(params: { + entries: ThreadBindingRecord[]; + minBoundAt: number; + targetAgent: string; +}): ThreadBindingRecord[] { + const normalizedTargetAgent = params.targetAgent.trim().toLowerCase(); + return params.entries + .filter((entry) => { + const targetKind = String(entry.targetKind || "") + .trim() + .toLowerCase(); + if (targetKind !== "acp") { + return false; + } + if (normalizeBoundAt(entry) < params.minBoundAt) { + return false; + } + const agentId = String(entry.agentId || "") + .trim() + .toLowerCase(); + if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) { + return false; + } + return true; + }) + .toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a)); +} + +function buildInstruction(params: { + smokeId: string; + ackToken: string; + targetAgent: string; + mentionUserId?: string; + template?: string; +}): string { + const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : ""; + if (params.template?.trim()) { + return mentionPrefix + params.template.trim(); + } + return ( + mentionPrefix + + `Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.` + ); +} + +function toRecentMessageRow(message: DiscordMessage) { + return { + id: message.id, + author: message.author?.username || message.author?.id || "unknown", + bot: Boolean(message.author?.bot), + content: (message.content || "").slice(0, 500), + }; +} + +function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) { + if (params.json) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(params.payload, null, 2)); + return; + } + if (params.payload.ok) { + const success = params.payload; + // eslint-disable-next-line no-console + console.log("PASS"); + // eslint-disable-next-line no-console + console.log(`smokeId: ${success.smokeId}`); + // eslint-disable-next-line no-console + console.log(`sentMessageId: ${success.sentMessageId}`); + // eslint-disable-next-line no-console + console.log(`threadId: ${success.binding.threadId}`); + // eslint-disable-next-line no-console + console.log(`sessionKey: ${success.binding.targetSessionKey}`); + // eslint-disable-next-line no-console + console.log(`ackMessageId: ${success.ackMessage.id}`); + // eslint-disable-next-line no-console + console.log( + `ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`, + ); + return; + } + const failure = params.payload; + // eslint-disable-next-line no-console + console.error("FAIL"); + // eslint-disable-next-line no-console + console.error(`stage: ${failure.stage}`); + // eslint-disable-next-line no-console + console.error(`smokeId: ${failure.smokeId}`); + // eslint-disable-next-line no-console + console.error(`error: ${failure.error}`); + if (failure.diagnostics?.bindingCandidates?.length) { + // eslint-disable-next-line no-console + console.error("binding candidates:"); + for (const candidate of failure.diagnostics.bindingCandidates) { + // eslint-disable-next-line no-console + console.error( + ` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`, + ); + } + } + if (failure.diagnostics?.parentChannelRecent?.length) { + // eslint-disable-next-line no-console + console.error("recent parent channel messages:"); + for (const row of failure.diagnostics.parentChannelRecent) { + // eslint-disable-next-line no-console + console.error(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`); + } + } +} + +async function run(): Promise { + let args: Args; + try { + args = parseArgs(); + } catch (err) { + return { + ok: false, + stage: "validation", + smokeId: "n/a", + error: err instanceof Error ? err.message : String(err), + }; + } + + const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`; + const ackToken = `ACP_SMOKE_ACK_${smokeId}`; + const instruction = buildInstruction({ + smokeId, + ackToken, + targetAgent: args.targetAgent, + mentionUserId: args.mentionUserId, + template: args.instruction, + }); + + let readAuthHeader = ""; + let sentMessageId = ""; + let setupStage: "discord-api" | "send-message" = "discord-api"; + let senderAuthorId: string | undefined; + let webhookForCleanup: + | { + id: string; + token: string; + } + | undefined; + + try { + if (args.driverMode === "token") { + const authHeader = resolveAuthorizationHeader({ + token: args.driverToken, + tokenPrefix: args.driverTokenPrefix, + }); + readAuthHeader = authHeader; + + const driverUser = await discordApi({ + method: "GET", + path: "/users/@me", + authHeader, + }); + senderAuthorId = driverUser.id; + + setupStage = "send-message"; + const sent = await discordApi({ + method: "POST", + path: `/channels/${encodeURIComponent(args.channelId)}/messages`, + authHeader, + body: { + content: instruction, + allowed_mentions: args.mentionUserId + ? { parse: [], users: [args.mentionUserId] } + : { parse: [] }, + }, + }); + sentMessageId = sent.id; + } else { + const botAuthHeader = resolveAuthorizationHeader({ + token: args.botToken, + tokenPrefix: args.botTokenPrefix, + }); + readAuthHeader = botAuthHeader; + + await discordApi({ + method: "GET", + path: "/users/@me", + authHeader: botAuthHeader, + }); + + setupStage = "send-message"; + const webhook = await discordApi<{ id: string; token?: string | null }>({ + method: "POST", + path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`, + authHeader: botAuthHeader, + body: { + name: `openclaw-acp-smoke-${smokeId.slice(-8)}`, + }, + }); + if (!webhook.id || !webhook.token) { + return { + ok: false, + stage: "send-message", + smokeId, + error: + "Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.", + }; + } + webhookForCleanup = { id: webhook.id, token: webhook.token }; + + const sent = await discordWebhookApi({ + method: "POST", + webhookId: webhook.id, + webhookToken: webhook.token, + query: "wait=true", + body: { + content: instruction, + allowed_mentions: args.mentionUserId + ? { parse: [], users: [args.mentionUserId] } + : { parse: [] }, + }, + }); + sentMessageId = sent.id; + senderAuthorId = sent.author?.id; + } + } catch (err) { + return { + ok: false, + stage: setupStage, + smokeId, + error: err instanceof Error ? err.message : String(err), + }; + } + + const startedAt = Date.now(); + + const deadline = startedAt + args.timeoutMs; + let winningBinding: ThreadBindingRecord | undefined; + let latestCandidates: ThreadBindingRecord[] = []; + + try { + while (Date.now() < deadline && !winningBinding) { + try { + const entries = await readThreadBindings(args.threadBindingsPath); + latestCandidates = resolveCandidateBindings({ + entries, + minBoundAt: startedAt - 3_000, + targetAgent: args.targetAgent, + }); + winningBinding = latestCandidates[0]; + } catch { + // Keep polling; file may not exist yet or may be mid-write. + } + if (!winningBinding) { + await sleep(args.pollMs); + } + } + + if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) { + let parentRecent: DiscordMessage[] = []; + try { + parentRecent = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, + authHeader: readAuthHeader, + }); + } catch { + // Best effort diagnostics only. + } + return { + ok: false, + stage: "wait-binding", + smokeId, + error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`, + diagnostics: { + bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({ + threadId: entry.threadId || "", + targetSessionKey: entry.targetSessionKey || "", + targetKind: entry.targetKind, + agentId: entry.agentId, + boundAt: entry.boundAt, + })), + parentChannelRecent: parentRecent.map(toRecentMessageRow), + }, + }; + } + + const threadId = winningBinding.threadId; + let ackMessage: DiscordMessage | undefined; + while (Date.now() < deadline && !ackMessage) { + try { + const threadMessages = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`, + authHeader: readAuthHeader, + }); + ackMessage = threadMessages.find((message) => { + const content = message.content || ""; + if (!content.includes(ackToken)) { + return false; + } + const authorId = message.author?.id || ""; + return !senderAuthorId || authorId !== senderAuthorId; + }); + } catch { + // Keep polling; thread can appear before read permissions settle. + } + if (!ackMessage) { + await sleep(args.pollMs); + } + } + + if (!ackMessage) { + let parentRecent: DiscordMessage[] = []; + try { + parentRecent = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, + authHeader: readAuthHeader, + }); + } catch { + // Best effort diagnostics only. + } + + return { + ok: false, + stage: "wait-ack", + smokeId, + error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`, + diagnostics: { + bindingCandidates: [ + { + threadId: winningBinding.threadId || "", + targetSessionKey: winningBinding.targetSessionKey || "", + targetKind: winningBinding.targetKind, + agentId: winningBinding.agentId, + boundAt: winningBinding.boundAt, + }, + ], + parentChannelRecent: parentRecent.map(toRecentMessageRow), + }, + }; + } + + return { + ok: true, + smokeId, + ackToken, + sentMessageId, + binding: { + threadId, + targetSessionKey: winningBinding.targetSessionKey, + targetKind: String(winningBinding.targetKind || "acp"), + agentId: String(winningBinding.agentId || args.targetAgent), + boundAt: normalizeBoundAt(winningBinding), + accountId: winningBinding.accountId, + channelId: winningBinding.channelId, + }, + ackMessage: { + id: ackMessage.id, + authorId: ackMessage.author?.id, + authorUsername: ackMessage.author?.username, + timestamp: ackMessage.timestamp, + content: ackMessage.content, + }, + }; + } finally { + if (webhookForCleanup) { + await discordWebhookApi({ + method: "DELETE", + webhookId: webhookForCleanup.id, + webhookToken: webhookForCleanup.token, + }).catch(() => { + // Best-effort cleanup only. + }); + } + } +} + +if (hasFlag("--help") || hasFlag("-h")) { + // eslint-disable-next-line no-console + console.log(usage()); + process.exit(0); +} + +const result = await run().catch( + (err): FailureResult => ({ + ok: false, + stage: "unexpected", + smokeId: "n/a", + error: err instanceof Error ? err.message : String(err), + }), +); + +printOutput({ + json: hasFlag("--json"), + payload: result, +}); + +process.exit(result.ok ? 0 : 1); diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 0aa0773a5de7..0749fc13f2d9 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -22,20 +22,23 @@ echo "Creating Docker network..." docker network create "$NET_NAME" >/dev/null echo "Starting gateway container..." - docker run --rm -d \ - --name "$GW_NAME" \ - --network "$NET_NAME" \ - -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ - -e "OPENCLAW_SKIP_CHANNELS=1" \ - -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ - -e "OPENCLAW_SKIP_CRON=1" \ - -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - "$IMAGE_NAME" \ - bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" +docker run -d \ + --name "$GW_NAME" \ + --network "$NET_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CRON=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail; entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" echo "Waiting for gateway to come up..." ready=0 for _ in $(seq 1 40); do + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then + break + fi if docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' import net from \"node:net\"; const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT }); @@ -65,7 +68,11 @@ done if [ "$ready" -ne 1 ]; then echo "Gateway failed to start" - docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then + docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + else + docker logs "$GW_NAME" 2>&1 | tail -n 120 || true + fi exit 1 fi diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index bdfb0ca6b3e1..0f7a894e394c 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -409,6 +409,7 @@ NODE # Seed a remote config to exercise reset path. cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' { + "meta": {}, "agents": { "defaults": { "workspace": "/root/old" } }, "gateway": { "mode": "remote", @@ -504,6 +505,7 @@ NODE # Seed skills config to ensure it survives the wizard. cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' { + "meta": {}, "skills": { "allowBundled": ["__none__"], "install": { "nodeManager": "bun" } diff --git a/scripts/generate-host-env-security-policy-swift.mjs b/scripts/generate-host-env-security-policy-swift.mjs new file mode 100644 index 000000000000..4de64ad8d989 --- /dev/null +++ b/scripts/generate-host-env-security-policy-swift.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const args = new Set(process.argv.slice(2)); +const checkOnly = args.has("--check"); +const writeMode = args.has("--write") || !checkOnly; + +if (checkOnly && args.has("--write")) { + console.error("Use either --check or --write, not both."); + process.exit(1); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const policyPath = path.join(repoRoot, "src", "infra", "host-env-security-policy.json"); +const outputPath = path.join( + repoRoot, + "apps", + "macos", + "Sources", + "OpenClaw", + "HostEnvSecurityPolicy.generated.swift", +); + +/** @type {{blockedKeys: string[]; blockedOverrideKeys?: string[]; blockedPrefixes: string[]}} */ +const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); + +const renderSwiftStringArray = (items) => items.map((item) => ` "${item}"`).join(",\n"); + +const generated = `// Generated file. Do not edit directly. +// Source: src/infra/host-env-security-policy.json +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write + +import Foundation + +enum HostEnvSecurityPolicy { + static let blockedKeys: Set = [ +${renderSwiftStringArray(policy.blockedKeys)} + ] + + static let blockedOverrideKeys: Set = [ +${renderSwiftStringArray(policy.blockedOverrideKeys ?? [])} + ] + + static let blockedPrefixes: [String] = [ +${renderSwiftStringArray(policy.blockedPrefixes)} + ] +} +`; + +const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null; + +if (checkOnly) { + if (current === generated) { + console.log(`OK ${path.relative(repoRoot, outputPath)}`); + process.exit(0); + } + console.error( + [ + `Out of date ${path.relative(repoRoot, outputPath)}.`, + "Run: node scripts/generate-host-env-security-policy-swift.mjs --write", + ].join("\n"), + ); + process.exit(1); +} + +if (writeMode) { + if (current !== generated) { + fs.writeFileSync(outputPath, generated); + } + console.log(`Wrote ${path.relative(repoRoot, outputPath)}`); +} diff --git a/scripts/ghsa-patch.mjs b/scripts/ghsa-patch.mjs new file mode 100644 index 000000000000..44e7daa2beee --- /dev/null +++ b/scripts/ghsa-patch.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function usage() { + console.error( + [ + "Usage:", + " node scripts/ghsa-patch.mjs --ghsa [--repo owner/name]", + " --summary --severity ", + " --description-file ", + " --vulnerable-version-range ", + " --patched-versions ", + " [--package openclaw] [--ecosystem npm] [--cvss ]", + ].join("\n"), + ); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + fail(`Unexpected argument: ${arg}`); + } + const key = arg.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + fail(`Missing value for --${key}`); + } + out[key] = value; + i += 1; + } + return out; +} + +function runGh(args) { + const proc = spawnSync("gh", args, { encoding: "utf8" }); + if (proc.status !== 0) { + fail(proc.stderr.trim() || proc.stdout.trim() || `gh ${args.join(" ")} failed`); + } + return proc.stdout; +} + +function deriveRepoFromOrigin() { + const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); + const httpsMatch = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/); + if (!httpsMatch) { + fail(`Could not parse origin remote: ${remote}`); + } + return `${httpsMatch[1]}/${httpsMatch[2]}`; +} + +function parseGhsaId(value) { + const match = value.match(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i); + if (!match) { + fail(`Could not parse GHSA id from: ${value}`); + } + return match[0]; +} + +function writeTempJson(data) { + const file = path.join(os.tmpdir(), `ghsa-patch-${crypto.randomUUID()}.json`); + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); + return file; +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.ghsa || !args.summary || !args.severity || !args["description-file"]) { + usage(); + process.exit(1); +} + +const repo = args.repo || deriveRepoFromOrigin(); +const ghsaId = parseGhsaId(args.ghsa); +const advisoryPath = `/repos/${repo}/security-advisories/${ghsaId}`; +const descriptionPath = path.resolve(args["description-file"]); + +if (!fs.existsSync(descriptionPath)) { + fail(`Description file does not exist: ${descriptionPath}`); +} + +const current = JSON.parse(runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath])); +const restoredCvss = args.cvss || current?.cvss?.vector_string || null; + +const ecosystem = args.ecosystem || "npm"; +const packageName = args.package || "openclaw"; +const vulnerableRange = args["vulnerable-version-range"]; +const patchedVersionsRaw = args["patched-versions"]; + +if (!vulnerableRange) { + fail("Missing --vulnerable-version-range"); +} +if (patchedVersionsRaw === undefined) { + fail("Missing --patched-versions"); +} + +const patchedVersions = patchedVersionsRaw === "null" ? null : patchedVersionsRaw; +const description = fs.readFileSync(descriptionPath, "utf8"); + +const payload = { + summary: args.summary, + severity: args.severity, + description, + vulnerabilities: [ + { + package: { + ecosystem, + name: packageName, + }, + vulnerable_version_range: vulnerableRange, + patched_versions: patchedVersions, + vulnerable_functions: [], + }, + ], +}; + +const patchFile = writeTempJson(payload); +runGh([ + "api", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-X", + "PATCH", + advisoryPath, + "--input", + patchFile, +]); + +if (restoredCvss) { + runGh([ + "api", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-X", + "PATCH", + advisoryPath, + "-f", + `cvss_vector_string=${restoredCvss}`, + ]); +} + +const refreshed = JSON.parse( + runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath]), +); +console.log( + JSON.stringify( + { + html_url: refreshed.html_url, + state: refreshed.state, + severity: refreshed.severity, + summary: refreshed.summary, + vulnerabilities: refreshed.vulnerabilities, + cvss: refreshed.cvss, + updated_at: refreshed.updated_at, + }, + null, + 2, + ), +); diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index 9ce1a89f2db6..0963d8d84994 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -10,15 +10,35 @@ preferred_team="${IOS_PREFERRED_TEAM_ID:-${OPENCLAW_IOS_DEFAULT_TEAM_ID:-Y5PE65H preferred_team_name="${IOS_PREFERRED_TEAM_NAME:-}" allow_keychain_fallback="${IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK:-0}" prefer_non_free_team="${IOS_PREFER_NON_FREE_TEAM:-1}" +preferred_team="${preferred_team//$'\r'/}" +preferred_team_name="${preferred_team_name//$'\r'/}" declare -a team_ids=() declare -a team_is_free=() declare -a team_names=() +python_cmd="" + +detect_python() { + local candidate + for candidate in "${IOS_PYTHON_BIN:-}" python3 python /usr/bin/python3; do + [[ -n "$candidate" ]] || continue + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +python_cmd="$(detect_python || true)" append_team() { local candidate_id="$1" local candidate_is_free="$2" local candidate_name="$3" + candidate_id="${candidate_id//$'\r'/}" + candidate_is_free="${candidate_is_free//$'\r'/}" + candidate_name="${candidate_name//$'\r'/}" [[ -z "$candidate_id" ]] && return local i @@ -36,13 +56,14 @@ append_team() { load_teams_from_xcode_preferences() { local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" [[ -f "$plist_path" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 while IFS=$'\t' read -r team_id is_free team_name; do [[ -z "$team_id" ]] && continue append_team "$team_id" "${is_free:-0}" "${team_name:-}" done < <( plutil -extract IDEProvisioningTeams json -o - "$plist_path" 2>/dev/null \ - | /usr/bin/python3 -c ' + | "$python_cmd" -c ' import json import sys @@ -80,9 +101,49 @@ load_teams_from_legacy_defaults_key() { ) } +load_teams_from_xcode_managed_profiles() { + local profiles_dir="${HOME}/Library/MobileDevice/Provisioning Profiles" + [[ -d "$profiles_dir" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 + + while IFS= read -r team; do + [[ -z "$team" ]] && continue + append_team "$team" "0" "" + done < <( + for p in "${profiles_dir}"/*.mobileprovision; do + [[ -f "$p" ]] || continue + security cms -D -i "$p" 2>/dev/null \ + | "$python_cmd" -c ' +import plistlib, sys +try: + raw = sys.stdin.buffer.read() + if not raw: + raise SystemExit(0) + d = plistlib.loads(raw) + for tid in d.get("TeamIdentifier", []): + print(tid) +except Exception: + pass +' 2>/dev/null + done | sort -u + ) +} + +has_xcode_account() { + local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" + [[ -f "$plist_path" ]] || return 1 + local accts + accts="$(defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists 2>/dev/null || true)" + [[ -n "$accts" ]] && [[ "$accts" != *"does not exist"* ]] && grep -q 'identifier' <<< "$accts" +} + load_teams_from_xcode_preferences load_teams_from_legacy_defaults_key +if [[ ${#team_ids[@]} -eq 0 ]]; then + load_teams_from_xcode_managed_profiles +fi + if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then while IFS= read -r team; do [[ -z "$team" ]] && continue @@ -95,7 +156,19 @@ if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then fi if [[ ${#team_ids[@]} -eq 0 ]]; then - if [[ "$allow_keychain_fallback" == "1" ]]; then + if has_xcode_account; then + echo "An Apple account is signed in to Xcode, but no Team ID could be resolved." >&2 + echo "" >&2 + echo "On Xcode 16+, team data is not written until you build a project." >&2 + echo "To fix this, do ONE of the following:" >&2 + echo "" >&2 + echo " 1. Open the iOS project in Xcode, select your Team in Signing &" >&2 + echo " Capabilities, and build once. Then re-run this script." >&2 + echo "" >&2 + echo " 2. Set your Team ID directly:" >&2 + echo " export IOS_DEVELOPMENT_TEAM=" >&2 + echo " Find your Team ID at: https://developer.apple.com/account#MembershipDetailsCard" >&2 + elif [[ "$allow_keychain_fallback" == "1" ]]; then echo "No Apple Team ID found. Open Xcode or install signing certificates first." >&2 else echo "No Apple Team ID found in Xcode accounts. Open Xcode → Settings → Accounts and sign in, then retry." >&2 diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh index 437c68e8bebb..df5c249caf3a 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -19,7 +19,8 @@ ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then - if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + # Accept legacy calver suffixes like -1 and prerelease forms like -beta.1 / .beta.1. + if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?)\.zip$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 diff --git a/scripts/pr b/scripts/pr index 90cfe029db08..215b72bcbb0c 100755 --- a/scripts/pr +++ b/scripts/pr @@ -664,6 +664,99 @@ validate_changelog_entry_for_pr() { echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" } +validate_changelog_merge_hygiene() { + local diff + diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md) + + local removed_lines + removed_lines=$(printf '%s\n' "$diff" | awk ' + /^---/ { next } + /^-/ { print substr($0, 2) } + ') + if [ -z "$removed_lines" ]; then + return 0 + fi + + local removed_refs + removed_refs=$(printf '%s\n' "$removed_lines" | rg -o '#[0-9]+' | sort -u || true) + if [ -z "$removed_refs" ]; then + return 0 + fi + + local added_lines + added_lines=$(printf '%s\n' "$diff" | awk ' + /^\+\+\+/ { next } + /^\+/ { print substr($0, 2) } + ') + + local ref + while IFS= read -r ref; do + [ -z "$ref" ] && continue + if ! printf '%s\n' "$added_lines" | rg -q -F "$ref"; then + echo "CHANGELOG.md drops existing entry reference $ref without re-adding it." + echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)." + exit 1 + fi + done <<<"$removed_refs" + + echo "changelog merge hygiene validated: no dropped PR references" +} + +changed_changelog_fragment_files() { + git diff --name-only origin/main...HEAD -- changelog/fragments | rg '^changelog/fragments/.*\.md$' || true +} + +validate_changelog_fragments_for_pr() { + local pr="$1" + local contrib="$2" + shift 2 + + if [ "$#" -lt 1 ]; then + echo "No changelog fragments provided for validation." + exit 1 + fi + + local pr_pattern + pr_pattern="(#$pr|openclaw#$pr)" + + local added_lines + local file + local all_added_lines="" + for file in "$@"; do + added_lines=$(git diff --unified=0 origin/main...HEAD -- "$file" | awk ' + /^\+\+\+/ { next } + /^\+/ { print substr($0, 2) } + ') + + if [ -z "$added_lines" ]; then + echo "$file is in diff but no added lines were detected." + exit 1 + fi + + all_added_lines=$(printf '%s\n%s\n' "$all_added_lines" "$added_lines") + done + + local with_pr + with_pr=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" || true) + if [ -z "$with_pr" ]; then + echo "Changelog fragment update must reference PR #$pr (for example, (#$pr))." + exit 1 + fi + + if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then + local with_pr_and_thanks + with_pr_and_thanks=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true) + if [ -z "$with_pr_and_thanks" ]; then + echo "Changelog fragment update must include both PR #$pr and thanks @$contrib on the entry line." + exit 1 + fi + echo "changelog fragments validated: found PR #$pr + thanks @$contrib" + return 0 + fi + + echo "changelog fragments validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" +} + prepare_gates() { local pr="$1" enter_worktree "$pr" false @@ -684,13 +777,31 @@ prepare_gates() { docs_only=true fi - # Enforce workflow policy: every prepared PR must include a changelog update. - if ! printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then - echo "Missing CHANGELOG.md update in PR diff. This workflow requires a changelog entry." + local has_changelog_update=false + if printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then + has_changelog_update=true + fi + local fragment_files + fragment_files=$(changed_changelog_fragment_files) + local has_fragment_update=false + if [ -n "$fragment_files" ]; then + has_fragment_update=true + fi + # Enforce workflow policy: every prepared PR must include either CHANGELOG.md + # or one or more changelog fragments. + if [ "$has_changelog_update" = "false" ] && [ "$has_fragment_update" = "false" ]; then + echo "Missing changelog update. Add CHANGELOG.md changes or changelog/fragments/*.md entry." exit 1 fi local contrib="${PR_AUTHOR:-}" - validate_changelog_entry_for_pr "$pr" "$contrib" + if [ "$has_changelog_update" = "true" ]; then + validate_changelog_merge_hygiene + validate_changelog_entry_for_pr "$pr" "$contrib" + fi + if [ "$has_fragment_update" = "true" ]; then + mapfile -t fragment_file_list <<<"$fragment_files" + validate_changelog_fragments_for_pr "$pr" "$contrib" "${fragment_file_list[@]}" + fi run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 7e2bd4490445..0ccc3efc1de4 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,7 +22,12 @@ type PackageJson = { }; function normalizePluginSyncVersion(version: string): string { - return version.replace(/[-+].*$/, ""); + const normalized = version.trim().replace(/^v/, ""); + const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; + if (base) { + return base; + } + return normalized.replace(/[-+].*$/, ""); } function runPackDry(): PackResult[] { diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 0db3fad39b0e..ba1aab336b61 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -265,5 +265,5 @@ else fi if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then - run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/bot.molt.gateway.plist' | head -n 40 || true" + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/ai.openclaw.gateway.plist' | head -n 40 || true" fi diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index 2be9d0a53046..9f0cd0bb6d5d 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -75,7 +75,9 @@ OPENCLAW_IMAGE="${OPENCLAW_PODMAN_IMAGE:-openclaw:local}" PODMAN_PULL="${OPENCLAW_PODMAN_PULL:-never}" HOST_GATEWAY_PORT="${OPENCLAW_PODMAN_GATEWAY_HOST_PORT:-${OPENCLAW_GATEWAY_PORT:-18789}}" HOST_BRIDGE_PORT="${OPENCLAW_PODMAN_BRIDGE_HOST_PORT:-${OPENCLAW_BRIDGE_PORT:-18790}}" -GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" +# Keep Podman default local-only unless explicitly overridden. +# Non-loopback binds require gateway.controlUi.allowedOrigins (security hardening). +GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-loopback}" # Safe cwd for podman (openclaw is nologin; avoid inherited cwd from sudo) cd "$EFFECTIVE_HOME" 2>/dev/null || cd /tmp 2>/dev/null || true diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts index 865b9b7d4cfe..651d44f1944e 100644 --- a/scripts/sync-plugin-versions.ts +++ b/scripts/sync-plugin-versions.ts @@ -4,25 +4,9 @@ import { join, resolve } from "node:path"; type PackageJson = { name?: string; version?: string; + devDependencies?: Record; }; -const rootPackagePath = resolve("package.json"); -const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; -const targetVersion = rootPackage.version; - -if (!targetVersion) { - throw new Error("Root package.json missing version."); -} - -const extensionsDir = resolve("extensions"); -const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), -); - -const updated: string[] = []; -const changelogged: string[] = []; -const skipped: string[] = []; - function ensureChangelogEntry(changelogPath: string, version: string): boolean { if (!existsSync(changelogPath)) { return false; @@ -42,35 +26,83 @@ function ensureChangelogEntry(changelogPath: string, version: string): boolean { return true; } -for (const dir of dirs) { - const packagePath = join(extensionsDir, dir.name, "package.json"); - let pkg: PackageJson; - try { - pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; - } catch { - continue; +function stripWorkspaceOpenclawDevDependency(pkg: PackageJson): boolean { + const devDeps = pkg.devDependencies; + if (!devDeps || devDeps.openclaw !== "workspace:*") { + return false; } - - if (!pkg.name) { - skipped.push(dir.name); - continue; + delete devDeps.openclaw; + if (Object.keys(devDeps).length === 0) { + delete pkg.devDependencies; } + return true; +} - const changelogPath = join(extensionsDir, dir.name, "CHANGELOG.md"); - if (ensureChangelogEntry(changelogPath, targetVersion)) { - changelogged.push(pkg.name); +export function syncPluginVersions(rootDir = resolve(".")) { + const rootPackagePath = join(rootDir, "package.json"); + const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; + const targetVersion = rootPackage.version; + if (!targetVersion) { + throw new Error("Root package.json missing version."); } - if (pkg.version === targetVersion) { - skipped.push(pkg.name); - continue; + const extensionsDir = join(rootDir, "extensions"); + const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + const updated: string[] = []; + const changelogged: string[] = []; + const skipped: string[] = []; + const strippedWorkspaceDevDeps: string[] = []; + + for (const dir of dirs) { + const packagePath = join(extensionsDir, dir.name, "package.json"); + let pkg: PackageJson; + try { + pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; + } catch { + continue; + } + + if (!pkg.name) { + skipped.push(dir.name); + continue; + } + + const changelogPath = join(extensionsDir, dir.name, "CHANGELOG.md"); + if (ensureChangelogEntry(changelogPath, targetVersion)) { + changelogged.push(pkg.name); + } + + const removedWorkspaceDevDependency = stripWorkspaceOpenclawDevDependency(pkg); + if (removedWorkspaceDevDependency) { + strippedWorkspaceDevDeps.push(pkg.name); + } + + const versionChanged = pkg.version !== targetVersion; + if (!versionChanged && !removedWorkspaceDevDependency) { + skipped.push(pkg.name); + continue; + } + + pkg.version = targetVersion; + writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + updated.push(pkg.name); } - pkg.version = targetVersion; - writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); - updated.push(pkg.name); + return { + targetVersion, + updated, + changelogged, + skipped, + strippedWorkspaceDevDeps, + }; } -console.log( - `Synced plugin versions to ${targetVersion}. Updated: ${updated.length}. Changelogged: ${changelogged.length}. Skipped: ${skipped.length}.`, -); +if (import.meta.main) { + const summary = syncPluginVersions(); + console.log( + `Synced plugin versions to ${summary.targetVersion}. Updated: ${summary.updated.length}. Changelogged: ${summary.changelogged.length}. Stripped workspace devDeps: ${summary.strippedWorkspaceDevDeps.length}. Skipped: ${summary.skipped.length}.`, + ); +} diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 26e1e9f1fc42..689647d739cc 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -21,7 +21,6 @@ docker run --rm -t \ -v "${LATEST_DIR}:/out" \ -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ -e OPENCLAW_INSTALL_METHOD=npm \ - -e OPENCLAW_USE_GUM=0 \ -e OPENCLAW_INSTALL_LATEST_OUT="/out/latest" \ -e OPENCLAW_INSTALL_SMOKE_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_PREVIOUS:-${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}}" \ -e OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS:-${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}}" \ @@ -47,7 +46,6 @@ else docker run --rm -t \ -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ -e OPENCLAW_INSTALL_METHOD=npm \ - -e OPENCLAW_USE_GUM=0 \ -e OPENCLAW_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \ -e OPENCLAW_NO_ONBOARD=1 \ -e DEBIAN_FRONTEND=noninteractive \ @@ -69,7 +67,6 @@ docker run --rm -t \ --entrypoint /bin/bash \ -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ -e OPENCLAW_INSTALL_CLI_URL="$CLI_INSTALL_URL" \ - -e OPENCLAW_USE_GUM=0 \ -e OPENCLAW_NO_ONBOARD=1 \ -e DEBIAN_FRONTEND=noninteractive \ "$NONROOT_IMAGE" -lc "curl -fsSL \"$CLI_INSTALL_URL\" | bash -s -- --set-npm-prefix --no-onboard" diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index bb0641df16b9..3cc5ed2bf0b6 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-modern}}" \ -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MAX_MODELS:-24}}" \ -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 1a7df857c7ae..f3aecc0049a9 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-all}}" \ + -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-modern}}" \ -e OPENCLAW_LIVE_PROVIDERS="${OPENCLAW_LIVE_PROVIDERS:-${CLAWDBOT_LIVE_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_MAX_MODELS="${OPENCLAW_LIVE_MAX_MODELS:-${CLAWDBOT_LIVE_MAX_MODELS:-48}}" \ -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 6ea080444c30..e866ef712ab1 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -12,11 +12,32 @@ const unitIsolatedFilesRaw = [ "src/plugins/tools.optional.test.ts", "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts", "src/security/fix.test.ts", + // Runtime source guard scans are sensitive to filesystem contention. + "src/security/temp-path-guard.test.ts", "src/security/audit.test.ts", "src/utils.test.ts", "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Process supervision + docker setup suites are stable but setup-heavy. + "src/process/supervisor/supervisor.test.ts", + "src/docker-setup.test.ts", + // Filesystem-heavy skills sync suite. + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + // Real git hook integration test; keep signal, move off unit-fast critical path. + "test/git-hooks-pre-commit.test.ts", + // Setup-heavy doctor command suites; keep them off the unit-fast critical path. + "src/commands/doctor.warns-state-directory-is-missing.test.ts", + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", + // Setup-heavy CLI update flow suite; move off unit-fast critical path. + "src/cli/update-cli.test.ts", + // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. + "src/config/schema.test.ts", + "src/config/schema.tags.test.ts", + // CLI smoke/agent flows are stable but setup-heavy. + "src/cli/program.smoke.test.ts", + "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", "src/web/media.test.ts", @@ -31,6 +52,26 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/reply.block-streaming.test.ts", // Archive extraction/fixture-heavy suite; keep off unit-fast critical path. "src/hooks/install.test.ts", + // Download/extraction safety cases can spike under unit-fast contention. + "src/agents/skills-install.download.test.ts", + // Heavy runner/exec/archive suites are stable but contend on shared resources under vmForks. + "src/agents/pi-embedded-runner.test.ts", + "src/agents/bash-tools.test.ts", + "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts", + "src/agents/bash-tools.exec.background-abort.test.ts", + "src/agents/subagent-announce.format.test.ts", + "src/infra/archive.test.ts", + "src/cli/daemon-cli.coverage.test.ts", + // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. + "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", + // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. + "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", + // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. + "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", + "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", + "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. "src/telegram/bot.create-telegram-bot.test.ts", // Medium-heavy bot behavior suite; move off unit-fast critical path. @@ -47,14 +88,20 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS"; const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows"; const isWindowsCi = isCI && isWindows; +const hostCpuCount = os.cpus().length; +const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); +// Keep aggressive local defaults for high-memory workstations (Mac Studio class). +const highMemLocalHost = !isCI && hostMemoryGiB >= 96; +const lowMemLocalHost = !isCI && hostMemoryGiB < 64; const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); // vmForks is a big win for transform/import heavy suites, but Node 24 had -// regressions with Vitest's vm runtime in this repo. Keep it opt-out via +// regressions with Vitest's vm runtime in this repo, and low-memory local hosts +// are more likely to hit per-worker V8 heap ceilings. Keep it opt-out via // OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor !== 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" || - (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks); + (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks && !lowMemLocalHost); const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const runs = [ ...(useVmForks @@ -113,11 +160,31 @@ const runs = [ }, ]; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); -const shardCount = isWindowsCi - ? Number.isFinite(shardOverride) && shardOverride > 1 - ? shardOverride - : 2 - : 1; +const configuredShardCount = + Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null; +const shardCount = configuredShardCount ?? (isWindowsCi ? 2 : 1); +const shardIndexOverride = (() => { + const parsed = Number.parseInt(process.env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +})(); + +if (shardIndexOverride !== null && shardCount <= 1) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} requires OPENCLAW_TEST_SHARDS>1.`, + ); + process.exit(2); +} + +if (shardIndexOverride !== null && shardIndexOverride > shardCount) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} exceeds OPENCLAW_TEST_SHARDS=${String(shardCount)}.`, + ); + process.exit(2); +} const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : []; const silentArgs = process.env.OPENCLAW_TEST_SHOW_PASSED_LOGS === "1" ? [] : ["--silent=passed-only"]; @@ -135,22 +202,32 @@ const testProfile = const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; -// Keep gateway serial on Windows CI and CI by default; run in parallel locally -// for lower wall-clock time. CI can opt in via OPENCLAW_TEST_PARALLEL_GATEWAY=1. +const parallelGatewayEnabled = + process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); +// Keep gateway serial by default except when explicitly requested or on high-memory local hosts. const keepGatewaySerial = isWindowsCi || process.env.OPENCLAW_TEST_SERIAL_GATEWAY === "1" || testProfile === "serial" || - (isCI && process.env.OPENCLAW_TEST_PARALLEL_GATEWAY !== "1"); + !parallelGatewayEnabled; const parallelRuns = keepGatewaySerial ? runs.filter((entry) => entry.name !== "gateway") : runs; const serialRuns = keepGatewaySerial ? runs.filter((entry) => entry.name === "gateway") : []; -const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); +const baseLocalWorkers = Math.max(4, Math.min(16, hostCpuCount)); +const loadAwareDisabledRaw = process.env.OPENCLAW_TEST_LOAD_AWARE?.trim().toLowerCase(); +const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false"; +const loadRatio = + !isCI && !loadAwareDisabled && process.platform !== "win32" && hostCpuCount > 0 + ? os.loadavg()[0] / hostCpuCount + : 0; +// Keep the fast-path unchanged on normal load; only throttle under extreme host pressure. +const extremeLoadScale = loadRatio >= 1.1 ? 0.75 : loadRatio >= 1 ? 0.85 : 1; +const localWorkers = Math.max(4, Math.min(16, Math.floor(baseLocalWorkers * extremeLoadScale))); const defaultWorkerBudget = testProfile === "low" ? { unit: 2, unitIsolated: 1, - extensions: 1, + extensions: 4, gateway: 1, } : testProfile === "serial" @@ -167,14 +244,29 @@ const defaultWorkerBudget = extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), } - : { - // Local `pnpm test` runs multiple vitest groups concurrently; - // keep per-group workers conservative to avoid pegging all cores. - unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), - unitIsolated: 1, - extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: 2, - }; + : highMemLocalHost + ? { + // High-memory local hosts can prioritize wall-clock speed. + unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), + unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + } + : lowMemLocalHost + ? { + // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. + unit: 2, + unitIsolated: 1, + extensions: 4, + gateway: 1, + } + : { + // 64-95 GiB local hosts: conservative split with some parallel headroom. + unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), + unitIsolated: 1, + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: 1, + }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. // In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. @@ -264,9 +356,15 @@ const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { const maxWorkers = maxWorkersForRun(entry.name); const reporterArgs = buildReporterArgs(entry, extraArgs); + // vmForks with a single worker has shown cross-file leakage in extension suites. + // Fall back to process forks when we intentionally clamp that lane to one worker. + const entryArgs = + entry.name === "extensions" && maxWorkers === 1 && entry.args.includes("--pool=vmForks") + ? entry.args.map((arg) => (arg === "--pool=vmForks" ? "--pool=forks" : arg)) + : entry.args; const args = maxWorkers ? [ - ...entry.args, + ...entryArgs, "--maxWorkers", String(maxWorkers), ...silentArgs, @@ -274,7 +372,7 @@ const runOnce = (entry, extraArgs = []) => ...windowsCiArgs, ...extraArgs, ] - : [...entry.args, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; + : [...entryArgs, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), @@ -313,6 +411,9 @@ const run = async (entry) => { if (shardCount <= 1) { return runOnce(entry); } + if (shardIndexOverride !== null) { + return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`]); + } for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { // eslint-disable-next-line no-await-in-loop const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b0193..0e106e65969d 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -15,7 +15,6 @@ const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); const readmePath = resolve("README.md"); -const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; const seedCommit = mapConfig.seedCommit ?? null; const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); @@ -98,33 +97,33 @@ for (const login of ensureLogins) { const entriesByKey = new Map(); for (const seed of seedEntries) { - const login = loginFromUrl(seed.html_url); - const resolvedLogin = - login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); - const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; - const avatar = - seed.avatar_url && !isGhostAvatar(seed.avatar_url) - ? normalizeAvatar(seed.avatar_url) - : placeholderAvatar; + const login = + loginFromUrl(seed.html_url) ?? + resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + const key = login.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(login); + if (!user) { + continue; + } + apiByLogin.set(key, user); const existing = entriesByKey.get(key); if (!existing) { - const user = resolvedLogin ? apiByLogin.get(key) : null; entriesByKey.set(key, { key, - login: resolvedLogin ?? login ?? undefined, + login: user.login, display: seed.display, - html_url: user?.html_url ?? seed.html_url, - avatar_url: user?.avatar_url ?? avatar, + html_url: user.html_url, + avatar_url: user.avatar_url, lines: 0, }); } else { existing.display = existing.display || seed.display; - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - existing.avatar_url = avatar; - } - if (!existing.html_url || existing.html_url.includes("/search?q=")) { - existing.html_url = seed.html_url; - } + existing.login = user.login; + existing.html_url = user.html_url; + existing.avatar_url = user.avatar_url; } } @@ -138,52 +137,37 @@ for (const item of contributors) { ? item.login : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); - if (resolvedLogin) { - const key = resolvedLogin.toLowerCase(); - const existing = entriesByKey.get(key); - if (!existing) { - let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - entriesByKey.set(key, { - key, - login: user.login, - display: pickDisplay(baseName, user.login, existing?.display), - html_url: user.html_url, - avatar_url: normalizeAvatar(user.avatar_url), - lines: lines > 0 ? lines : contributions, - }); - } - } else if (existing) { - existing.login = existing.login ?? resolvedLogin; - existing.display = pickDisplay(baseName, existing.login, existing.display); - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - existing.html_url = user.html_url; - existing.avatar_url = normalizeAvatar(user.avatar_url); - } - } - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); - } + if (!resolvedLogin) { + continue; + } + + const key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { continue; } + apiByLogin.set(key, user); - const anonKey = `name:${normalizeName(baseName)}`; - const existingAnon = entriesByKey.get(anonKey); - if (!existingAnon) { - entriesByKey.set(anonKey, { - key: anonKey, - display: baseName, - html_url: fallbackHref(baseName), - avatar_url: placeholderAvatar, - lines: item.contributions ?? 0, + const existing = entriesByKey.get(key); + if (!existing) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, }); } else { - existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + existing.login = user.login; + existing.display = pickDisplay(baseName, user.login, existing.display); + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); } } @@ -205,14 +189,6 @@ for (const [login, lines] of linesByLogin.entries()) { avatar_url: normalizeAvatar(user.avatar_url), lines: lines > 0 ? lines : contributions, }); - } else { - entriesByKey.set(login, { - key: login, - display: login, - html_url: fallbackHref(login), - avatar_url: placeholderAvatar, - lines, - }); } } @@ -323,10 +299,6 @@ function normalizeAvatar(url: string): string { return `${url}${sep}s=48`; } -function isGhostAvatar(url: string): boolean { - return url.toLowerCase().includes("ghost.png"); -} - function fetchUser(login: string): User | null { const normalized = normalizeLogin(login); if (!normalized) { diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md index ef4e059499d2..cca6ef83ad54 100644 --- a/skills/coding-agent/SKILL.md +++ b/skills/coding-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-agent -description: "Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true." +description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true.' metadata: { "openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } }, diff --git a/skills/food-order/SKILL.md b/skills/food-order/SKILL.md deleted file mode 100644 index 1708dd8ce39c..000000000000 --- a/skills/food-order/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: food-order -description: Reorder Foodora orders + track ETA/status with ordercli. Never confirm without explicit user approval. Triggers: order food, reorder, track ETA. -homepage: https://ordercli.sh -metadata: {"openclaw":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}} ---- - -# Food order (Foodora via ordercli) - -Goal: reorder a previous Foodora order safely (preview first; confirm only on explicit user “yes/confirm/place the order”). - -Hard safety rules - -- Never run `ordercli foodora reorder ... --confirm` unless user explicitly confirms placing the order. -- Prefer preview-only steps first; show what will happen; ask for confirmation. -- If user is unsure: stop at preview and ask questions. - -Setup (once) - -- Country: `ordercli foodora countries` → `ordercli foodora config set --country AT` -- Login (password): `ordercli foodora login --email you@example.com --password-stdin` -- Login (no password, preferred): `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"` - -Find what to reorder - -- Recent list: `ordercli foodora history --limit 10` -- Details: `ordercli foodora history show ` -- If needed (machine-readable): `ordercli foodora history show --json` - -Preview reorder (no cart changes) - -- `ordercli foodora reorder ` - -Place reorder (cart change; explicit confirmation required) - -- Confirm first, then run: `ordercli foodora reorder --confirm` -- Multiple addresses? Ask user for the right `--address-id` (take from their Foodora account / prior order data) and run: - - `ordercli foodora reorder --confirm --address-id ` - -Track the order - -- ETA/status (active list): `ordercli foodora orders` -- Live updates: `ordercli foodora orders --watch` -- Single order detail: `ordercli foodora order ` - -Debug / safe testing - -- Use a throwaway config: `ordercli --config /tmp/ordercli.json ...` diff --git a/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py index 0b71f96ea0f6..ea28fa0d983e 100644 --- a/skills/model-usage/scripts/model_usage.py +++ b/skills/model-usage/scripts/model_usage.py @@ -17,6 +17,16 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return parsed + + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -239,7 +249,7 @@ def main() -> int: parser.add_argument("--mode", choices=["current", "all"], default="current") parser.add_argument("--model", help="Explicit model name to report instead of auto-current.") parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).") - parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).") + parser.add_argument("--days", type=positive_int, help="Limit to last N days (based on daily rows).") parser.add_argument("--format", choices=["text", "json"], default="text") parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") diff --git a/skills/model-usage/scripts/test_model_usage.py b/skills/model-usage/scripts/test_model_usage.py new file mode 100644 index 000000000000..4d5273401de1 --- /dev/null +++ b/skills/model-usage/scripts/test_model_usage.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Tests for model_usage helpers. +""" + +import argparse +from datetime import date, timedelta +from unittest import TestCase, main + +from model_usage import filter_by_days, positive_int + + +class TestModelUsage(TestCase): + def test_positive_int_accepts_valid_numbers(self): + self.assertEqual(positive_int("1"), 1) + self.assertEqual(positive_int("7"), 7) + + def test_positive_int_rejects_zero_and_negative(self): + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("0") + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("-3") + + def test_filter_by_days_keeps_recent_entries(self): + today = date.today() + entries = [ + {"date": (today - timedelta(days=5)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": today.strftime("%Y-%m-%d"), "modelBreakdowns": []}, + ] + + filtered = filter_by_days(entries, 2) + + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0]["date"], (today - timedelta(days=1)).strftime("%Y-%m-%d")) + self.assertEqual(filtered[1]["date"], today.strftime("%Y-%m-%d")) + + +if __name__ == "__main__": + main() diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index 3365c20077ff..8d60882c4561 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -95,12 +95,13 @@ def main(): max_input_dim = 0 for img_path in args.input_images: try: - img = PILImage.open(img_path) - input_images.append(img) + with PILImage.open(img_path) as img: + copied = img.copy() + width, height = copied.size + input_images.append(copied) print(f"Loaded input image: {img_path}") # Track largest dimension for auto-resolution - width, height = img.size max_input_dim = max(max_input_dim, width, height) except Exception as e: print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py index 7bd59e361264..4043f1a8ed73 100644 --- a/skills/openai-image-gen/scripts/gen.py +++ b/skills/openai-image-gen/scripts/gen.py @@ -9,6 +9,7 @@ import sys import urllib.error import urllib.request +from html import escape as html_escape from pathlib import Path @@ -131,8 +132,8 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: [ f"""

- -
{it["prompt"]}
+ +
{html_escape(it["prompt"])}
""".strip() for it in items @@ -152,7 +153,7 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: code {{ color: #9cd1ff; }}

openai-image-gen

-

Output: {out_dir.as_posix()}

+

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

{thumbs}
diff --git a/skills/openai-image-gen/scripts/test_gen.py b/skills/openai-image-gen/scripts/test_gen.py new file mode 100644 index 000000000000..3f0a38d978f3 --- /dev/null +++ b/skills/openai-image-gen/scripts/test_gen.py @@ -0,0 +1,50 @@ +"""Tests for write_gallery HTML escaping (fixes #12538 - stored XSS).""" + +import tempfile +from pathlib import Path + +from gen import write_gallery + + +def test_write_gallery_escapes_prompt_xss(): + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) + items = [{"prompt": '', "file": "001-test.png"}] + write_gallery(out, items) + html = (out / "index.html").read_text() + assert "", baseHref, { + allowDataImage: true, + }), + ).toBeNull(); + }); + + it("rejects SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml,", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + + it("rejects base64-encoded SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIC8+", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + + it("rejects data image URLs unless explicitly enabled", () => { + expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); + }); + + it("rejects javascript URLs", () => { + expect(resolveSafeExternalUrl("javascript:alert(1)", baseHref)).toBeNull(); + }); + + it("rejects file URLs", () => { + expect(resolveSafeExternalUrl("file:///tmp/x.png", baseHref)).toBeNull(); + }); + + it("rejects empty values", () => { + expect(resolveSafeExternalUrl(" ", baseHref)).toBeNull(); + }); +}); + +describe("openExternalUrlSafe", () => { + it("nulls opener when window.open returns a proxy-like object", () => { + const openedLikeProxy = { + opener: { postMessage: () => void 0 }, + } as unknown as WindowProxy; + const openMock = vi.fn(() => openedLikeProxy); + vi.stubGlobal("window", { + location: { href: "https://openclaw.ai/chat" }, + open: openMock, + } as unknown as Window & typeof globalThis); + + const opened = openExternalUrlSafe("https://example.com/safe.png"); + + expect(openMock).toHaveBeenCalledWith( + "https://example.com/safe.png", + "_blank", + "noopener,noreferrer", + ); + expect(opened).toBe(openedLikeProxy); + expect(openedLikeProxy.opener).toBeNull(); + }); +}); diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts new file mode 100644 index 000000000000..ed5a99c86786 --- /dev/null +++ b/ui/src/ui/open-external-url.ts @@ -0,0 +1,73 @@ +const DATA_URL_PREFIX = "data:"; +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); +const BLOCKED_DATA_IMAGE_MIME_TYPES = new Set(["image/svg+xml"]); + +function isAllowedDataImageUrl(url: string): boolean { + if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return false; + } + + const commaIndex = url.indexOf(","); + if (commaIndex < DATA_URL_PREFIX.length) { + return false; + } + + const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); + const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + if (!mimeType.startsWith("image/")) { + return false; + } + + return !BLOCKED_DATA_IMAGE_MIME_TYPES.has(mimeType); +} + +export type ResolveSafeExternalUrlOptions = { + allowDataImage?: boolean; +}; + +export function resolveSafeExternalUrl( + rawUrl: string, + baseHref: string, + opts: ResolveSafeExternalUrlOptions = {}, +): string | null { + const candidate = rawUrl.trim(); + if (!candidate) { + return null; + } + + if (opts.allowDataImage === true && isAllowedDataImageUrl(candidate)) { + return candidate; + } + + if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return null; + } + + try { + const parsed = new URL(candidate, baseHref); + return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + } catch { + return null; + } +} + +export type OpenExternalUrlSafeOptions = ResolveSafeExternalUrlOptions & { + baseHref?: string; +}; + +export function openExternalUrlSafe( + rawUrl: string, + opts: OpenExternalUrlSafeOptions = {}, +): WindowProxy | null { + const baseHref = opts.baseHref ?? window.location.href; + const safeUrl = resolveSafeExternalUrl(rawUrl, baseHref, opts); + if (!safeUrl) { + return null; + } + + const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); + if (opened) { + opened.opener = null; + } + return opened; +} diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index dbeaa687336a..6f0fdc0ad4b1 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -18,7 +18,8 @@ export function formatNextRun(ms?: number | null) { if (!ms) { return "n/a"; } - return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; + const weekday = new Date(ms).toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; } export function formatSessionTokens(row: GatewaySessionRow) { diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index f64c9da6dd6e..d6fda9475c42 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach } from "vitest"; -import { OpenClawApp } from "../app.ts"; +import "../app.ts"; +import type { OpenClawApp } from "../app.ts"; export function mountApp(pathname: string) { window.history.replaceState({}, "", pathname); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 307bae9388f6..3c4091479b4b 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -286,6 +286,7 @@ export type ConfigSnapshot = { export type ConfigUiHint = { label?: string; help?: string; + tags?: string[]; group?: string; order?: number; advanced?: boolean; @@ -344,6 +345,35 @@ export type AgentsListResult = { agents: GatewayAgentRow[]; }; +export type ToolCatalogProfile = { + id: "minimal" | "coding" | "messaging" | "full"; + label: string; +}; + +export type ToolCatalogEntry = { + id: string; + label: string; + description: string; + source: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">; +}; + +export type ToolCatalogGroup = { + id: string; + label: string; + source: "core" | "plugin"; + pluginId?: string; + tools: ToolCatalogEntry[]; +}; + +export type ToolsCatalogResult = { + agentId: string; + profiles: ToolCatalogProfile[]; + groups: ToolCatalogGroup[]; +}; + export type AgentIdentityResult = { agentId: string; name: string; @@ -439,7 +469,7 @@ export type { export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } - | { kind: "cron"; expr: string; tz?: string }; + | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; @@ -449,6 +479,7 @@ export type CronPayload = | { kind: "agentTurn"; message: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -492,17 +523,58 @@ export type CronStatus = { nextWakeAtMs?: number | null; }; +export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +export type CronSortDir = "asc" | "desc"; +export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped"; +export type CronRunsStatusValue = "ok" | "error" | "skipped"; +export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; +export type CronRunScope = "job" | "all"; + export type CronRunLogEntry = { ts: number; jobId: string; - status: "ok" | "error" | "skipped"; + jobName?: string; + status?: CronRunsStatusValue; durationMs?: number; error?: string; summary?: string; + deliveryStatus?: CronDeliveryStatus; + deliveryError?: string; + delivered?: boolean; + runAtMs?: number; + nextRunAtMs?: number; + model?: string; + provider?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; sessionId?: string; sessionKey?: string; }; +export type CronJobsListResult = { + jobs?: CronJob[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + +export type CronRunsResult = { + entries?: CronRunLogEntry[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + export type SkillsStatusConfigCheck = { path: string; satisfied: boolean; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 724f2a92009a..f1087546c796 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -18,19 +18,27 @@ export type CronFormState = { name: string; description: string; agentId: string; + clearAgent: boolean; enabled: boolean; + deleteAfterRun: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; everyUnit: "minutes" | "hours" | "days"; cronExpr: string; cronTz: string; + scheduleExact: boolean; + staggerAmount: string; + staggerUnit: "seconds" | "minutes"; sessionTarget: "main" | "isolated"; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; + payloadModel: string; + payloadThinking: string; deliveryMode: "none" | "announce" | "webhook"; deliveryChannel: string; deliveryTo: string; + deliveryBestEffort: boolean; timeoutSeconds: string; }; diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts new file mode 100644 index 000000000000..1917e982e449 --- /dev/null +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -0,0 +1,102 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderAgentTools } from "./agents-panels-tools-skills.ts"; + +function createBaseParams(overrides: Partial[0]> = {}) { + return { + agentId: "main", + configForm: { + agents: { + list: [{ id: "main", tools: { profile: "full" } }], + }, + } as Record, + configLoading: false, + configSaving: false, + configDirty: false, + toolsCatalogLoading: false, + toolsCatalogError: null, + toolsCatalogResult: null, + onProfileChange: () => undefined, + onOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + ...overrides, + }; +} + +describe("agents tools panel (browser)", () => { + it("renders per-tool provenance badges and optional marker", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, + ], + groups: [ + { + id: "media", + label: "Media", + source: "core", + tools: [ + { + id: "tts", + label: "tts", + description: "Text-to-speech conversion", + source: "core", + defaultProfiles: [], + }, + ], + }, + { + id: "plugin:voice-call", + label: "voice-call", + source: "plugin", + pluginId: "voice-call", + tools: [ + { + id: "voice_call", + label: "voice_call", + description: "Voice call tool", + source: "plugin", + pluginId: "voice-call", + optional: true, + defaultProfiles: [], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const text = container.textContent ?? ""; + expect(text).toContain("core"); + expect(text).toContain("plugin:voice-call"); + expect(text).toContain("optional"); + }); + + it("shows fallback warning when runtime catalog fails", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogError: "unavailable", + toolsCatalogResult: null, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); + }); +}); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 687ec749a629..4e25aaefc316 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; +import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; import { isAllowedByPolicy, matchesList, @@ -23,6 +23,9 @@ export function renderAgentTools(params: { configLoading: boolean; configSaving: boolean; configDirty: boolean; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; onConfigReload: () => void; @@ -50,7 +53,17 @@ export function renderAgentTools(params: { const basePolicy = hasAgentAllow ? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] } : (resolveToolProfile(profile) ?? undefined); - const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id)); + const sections = + params.toolsCatalogResult?.groups?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.groups + : TOOL_SECTIONS; + const profileOptions = + params.toolsCatalogResult?.profiles?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.profiles + : PROFILE_OPTIONS; + const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id)); const resolveAllowed = (toolId: string) => { const baseAllowed = isAllowedByPolicy(toolId, basePolicy); @@ -139,6 +152,15 @@ export function renderAgentTools(params: { + ${ + params.toolsCatalogError + ? html` +
+ Could not load runtime tool catalog. Showing fallback list. +
+ ` + : nothing + } ${ !params.configForm ? html` @@ -191,7 +213,7 @@ export function renderAgentTools(params: {
Quick Presets
- ${PROFILE_OPTIONS.map( + ${profileOptions.map( (option) => html`
${ @@ -753,24 +1056,12 @@ function renderMapField(params: { hints, unsupported, disabled, + searchCriteria, showLabel: false, onPatch, }) }
-
`; })} diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 9512622d152e..124ca50a585e 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -1,7 +1,7 @@ import { html, nothing } from "lit"; import { icons } from "../icons.ts"; import type { ConfigUiHints } from "../types.ts"; -import { renderNode } from "./config-form.node.ts"; +import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; export type ConfigFormProps = { @@ -278,20 +278,27 @@ function getSectionIcon(key: string) { return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default; } -function matchesSearch(key: string, schema: JsonSchema, query: string): boolean { - if (!query) { +function matchesSearch(params: { + key: string; + schema: JsonSchema; + sectionValue: unknown; + uiHints: ConfigUiHints; + query: string; +}): boolean { + if (!params.query) { return true; } - const q = query.toLowerCase(); - const meta = SECTION_META[key]; + const criteria = parseConfigSearchQuery(params.query); + const q = criteria.text; + const meta = SECTION_META[params.key]; // Check key name - if (key.toLowerCase().includes(q)) { + if (q && params.key.toLowerCase().includes(q)) { return true; } // Check label and description - if (meta) { + if (q && meta) { if (meta.label.toLowerCase().includes(q)) { return true; } @@ -300,56 +307,13 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean } } - return schemaMatches(schema, q); -} - -function schemaMatches(schema: JsonSchema, query: string): boolean { - if (schema.title?.toLowerCase().includes(query)) { - return true; - } - if (schema.description?.toLowerCase().includes(query)) { - return true; - } - if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) { - return true; - } - - if (schema.properties) { - for (const [propKey, propSchema] of Object.entries(schema.properties)) { - if (propKey.toLowerCase().includes(query)) { - return true; - } - if (schemaMatches(propSchema, query)) { - return true; - } - } - } - - if (schema.items) { - const items = Array.isArray(schema.items) ? schema.items : [schema.items]; - for (const item of items) { - if (item && schemaMatches(item, query)) { - return true; - } - } - } - - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - if (schemaMatches(schema.additionalProperties, query)) { - return true; - } - } - - const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf; - if (unions) { - for (const entry of unions) { - if (entry && schemaMatches(entry, query)) { - return true; - } - } - } - - return false; + return matchesNodeSearch({ + schema: params.schema, + value: params.sectionValue, + path: [params.key], + hints: params.uiHints, + criteria, + }); } export function renderConfigForm(props: ConfigFormProps) { @@ -368,6 +332,7 @@ export function renderConfigForm(props: ConfigFormProps) { const unsupported = new Set(props.unsupportedPaths ?? []); const properties = schema.properties; const searchQuery = props.searchQuery ?? ""; + const searchCriteria = parseConfigSearchQuery(searchQuery); const activeSection = props.activeSection; const activeSubsection = props.activeSubsection ?? null; @@ -384,7 +349,16 @@ export function renderConfigForm(props: ConfigFormProps) { if (activeSection && key !== activeSection) { return false; } - if (searchQuery && !matchesSearch(key, node, searchQuery)) { + if ( + searchQuery && + !matchesSearch({ + key, + schema: node, + sectionValue: value[key], + uiHints: props.uiHints, + query: searchQuery, + }) + ) { return false; } return true; @@ -456,6 +430,7 @@ export function renderConfigForm(props: ConfigFormProps) { unsupported, disabled: props.disabled ?? false, showLabel: false, + searchCriteria, onPatch: props.onPatch, })} @@ -490,6 +465,7 @@ export function renderConfigForm(props: ConfigFormProps) { unsupported, disabled: props.disabled ?? false, showLabel: false, + searchCriteria, onPatch: props.onPatch, })} diff --git a/ui/src/ui/views/config-form.search.node.test.ts b/ui/src/ui/views/config-form.search.node.test.ts new file mode 100644 index 000000000000..ee2387ee3932 --- /dev/null +++ b/ui/src/ui/views/config-form.search.node.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { matchesNodeSearch, parseConfigSearchQuery } from "./config-form.node.ts"; + +const schema = { + type: "object", + properties: { + gateway: { + type: "object", + properties: { + auth: { + type: "object", + properties: { + token: { type: "string" }, + }, + }, + }, + }, + mode: { + type: "string", + enum: ["off", "token"], + }, + }, +}; + +describe("config form search", () => { + it("parses tag-prefixed query terms", () => { + const parsed = parseConfigSearchQuery("token tag:security tag:Auth"); + expect(parsed.text).toBe("token"); + expect(parsed.tags).toEqual(["security", "auth"]); + }); + + it("matches fields by tag through ui hints", () => { + const parsed = parseConfigSearchQuery("tag:security"); + const matched = matchesNodeSearch({ + schema: schema.properties.gateway, + value: {}, + path: ["gateway"], + hints: { + "gateway.auth.token": { tags: ["security", "secret"] }, + }, + criteria: parsed, + }); + expect(matched).toBe(true); + }); + + it("requires text and tag when combined", () => { + const positive = matchesNodeSearch({ + schema: schema.properties.gateway, + value: {}, + path: ["gateway"], + hints: { + "gateway.auth.token": { tags: ["security"] }, + }, + criteria: parseConfigSearchQuery("token tag:security"), + }); + expect(positive).toBe(true); + + const negative = matchesNodeSearch({ + schema: schema.properties.gateway, + value: {}, + path: ["gateway"], + hints: { + "gateway.auth.token": { tags: ["security"] }, + }, + criteria: parseConfigSearchQuery("mode tag:security"), + }); + expect(negative).toBe(false); + }); +}); diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index c7e7d81abe34..366671041daf 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -4,6 +4,8 @@ export type JsonSchema = { type?: string | string[]; title?: string; description?: string; + tags?: string[]; + "x-tags"?: string[]; properties?: Record; items?: JsonSchema | JsonSchema[]; additionalProperties?: JsonSchema | boolean; diff --git a/ui/src/ui/views/config-search.node.test.ts b/ui/src/ui/views/config-search.node.test.ts new file mode 100644 index 000000000000..d1a5a09d8378 --- /dev/null +++ b/ui/src/ui/views/config-search.node.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + appendTagFilter, + getTagFilters, + hasTagFilter, + removeTagFilter, + replaceTagFilters, + toggleTagFilter, +} from "./config-search.ts"; + +describe("config search tag helper", () => { + it("adds a tag when query is empty", () => { + expect(appendTagFilter("", "security")).toBe("tag:security"); + }); + + it("appends a tag to existing text query", () => { + expect(appendTagFilter("token", "security")).toBe("token tag:security"); + }); + + it("deduplicates existing tag filters case-insensitively", () => { + expect(appendTagFilter("token tag:Security", "security")).toBe("token tag:Security"); + }); + + it("detects exact tag terms", () => { + expect(hasTagFilter("tag:security token", "security")).toBe(true); + expect(hasTagFilter("tag:security-hard token", "security")).toBe(false); + }); + + it("removes only the selected active tag", () => { + expect(removeTagFilter("token tag:security tag:auth", "security")).toBe("token tag:auth"); + }); + + it("toggle removes active tag and keeps text", () => { + expect(toggleTagFilter("token tag:security", "security")).toBe("token"); + }); + + it("toggle adds missing tag", () => { + expect(toggleTagFilter("token", "channels")).toBe("token tag:channels"); + }); + + it("extracts unique normalized tags from query", () => { + expect(getTagFilters("token tag:Security tag:auth tag:security")).toEqual(["security", "auth"]); + }); + + it("replaces only tag filters and preserves free text", () => { + expect(replaceTagFilters("token tag:security mode", ["auth", "channels"])).toBe( + "token mode tag:auth tag:channels", + ); + }); +}); diff --git a/ui/src/ui/views/config-search.ts b/ui/src/ui/views/config-search.ts new file mode 100644 index 000000000000..f6973d3a2cdc --- /dev/null +++ b/ui/src/ui/views/config-search.ts @@ -0,0 +1,92 @@ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeTag(tag: string): string { + return tag.trim().toLowerCase(); +} + +export function getTagFilters(query: string): string[] { + const seen = new Set(); + const tags: string[] = []; + const pattern = /(^|\s)tag:([^\s]+)/gi; + const raw = query.trim(); + let match: RegExpExecArray | null = pattern.exec(raw); + while (match) { + const normalized = normalizeTag(match[2] ?? ""); + if (normalized && !seen.has(normalized)) { + seen.add(normalized); + tags.push(normalized); + } + match = pattern.exec(raw); + } + return tags; +} + +export function hasTagFilter(query: string, tag: string): boolean { + const normalizedTag = normalizeTag(tag); + if (!normalizedTag) { + return false; + } + const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "i"); + return pattern.test(query.trim()); +} + +export function appendTagFilter(query: string, tag: string): string { + const normalizedTag = normalizeTag(tag); + const trimmed = query.trim(); + if (!normalizedTag) { + return trimmed; + } + if (!trimmed) { + return `tag:${normalizedTag}`; + } + if (hasTagFilter(trimmed, normalizedTag)) { + return trimmed; + } + return `${trimmed} tag:${normalizedTag}`; +} + +export function removeTagFilter(query: string, tag: string): string { + const normalizedTag = normalizeTag(tag); + const trimmed = query.trim(); + if (!normalizedTag || !trimmed) { + return trimmed; + } + const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "ig"); + return trimmed.replace(pattern, " ").replace(/\s+/g, " ").trim(); +} + +export function replaceTagFilters(query: string, tags: readonly string[]): string { + const uniqueTags: string[] = []; + const seen = new Set(); + for (const tag of tags) { + const normalized = normalizeTag(tag); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + uniqueTags.push(normalized); + } + + const trimmed = query.trim(); + const withoutTags = trimmed + .replace(/(^|\s)tag:([^\s]+)/gi, " ") + .replace(/\s+/g, " ") + .trim(); + const tagTokens = uniqueTags.map((tag) => `tag:${tag}`).join(" "); + if (withoutTags && tagTokens) { + return `${withoutTags} ${tagTokens}`; + } + if (withoutTags) { + return withoutTags; + } + return tagTokens; +} + +export function toggleTagFilter(query: string, tag: string): string { + if (hasTagFilter(query, tag)) { + return removeTagFilter(query, tag); + } + return appendTagFilter(query, tag); +} diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index cdb7fc195c44..ec58ef6c8aa2 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -198,4 +198,35 @@ describe("config view", () => { input.dispatchEvent(new Event("input", { bubbles: true })); expect(onSearchChange).toHaveBeenCalledWith("gateway"); }); + + it("shows all tag options in compact tag picker", () => { + const container = document.createElement("div"); + render(renderConfig(baseProps()), container); + + const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map( + (option) => option.textContent?.trim(), + ); + expect(options).toContain("tag:security"); + expect(options).toContain("tag:advanced"); + expect(options).toHaveLength(15); + }); + + it("updates search query when toggling a tag option", () => { + const container = document.createElement("div"); + const onSearchChange = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onSearchChange, + }), + container, + ); + + const option = container.querySelector( + '.config-search__tag-option[data-tag="security"]', + ); + expect(option).toBeTruthy(); + option?.click(); + expect(onSearchChange).toHaveBeenCalledWith("tag:security"); + }); }); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 221f31e00505..5fa88c53aac8 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; +import { getTagFilters, replaceTagFilters } from "./config-search.ts"; export type ConfigProps = { raw: string; @@ -34,6 +35,24 @@ export type ConfigProps = { onUpdate: () => void; }; +const TAG_SEARCH_PRESETS = [ + "security", + "auth", + "network", + "access", + "privacy", + "observability", + "performance", + "reliability", + "storage", + "models", + "media", + "automation", + "channels", + "tools", + "advanced", +] as const; + // SVG Icons for sidebar (Lucide-style) const sidebarIcons = { all: html` @@ -443,6 +462,7 @@ export function renderConfig(props: ConfigProps) { hasChanges && (props.formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; + const selectedTags = new Set(getTagFilters(props.searchQuery)); return html`
@@ -460,35 +480,91 @@ export function renderConfig(props: ConfigProps) { diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 839566151cd6..b09100494f7a 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -22,32 +22,93 @@ function createProps(overrides: Partial = {}): CronProps { return { basePath: "", loading: false, + jobsLoadingMore: false, status: null, jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsQuery: "", + jobsEnabledFilter: "all", + jobsSortBy: "nextRunAtMs", + jobsSortDir: "asc", error: null, busy: false, form: { ...DEFAULT_CRON_FORM }, + fieldErrors: {}, + canSubmit: true, + editingJobId: null, channels: [], channelLabels: {}, runsJobId: null, runs: [], + runsTotal: 0, + runsHasMore: false, + runsLoadingMore: false, + runsScope: "all", + runsStatuses: [], + runsDeliveryStatuses: [], + runsStatusFilter: "all", + runsQuery: "", + runsSortDir: "desc", + agentSuggestions: [], + modelSuggestions: [], + thinkingSuggestions: [], + timezoneSuggestions: [], + deliveryToSuggestions: [], onFormChange: () => undefined, onRefresh: () => undefined, onAdd: () => undefined, + onEdit: () => undefined, + onClone: () => undefined, + onCancelEdit: () => undefined, onToggle: () => undefined, onRun: () => undefined, onRemove: () => undefined, onLoadRuns: () => undefined, + onLoadMoreJobs: () => undefined, + onJobsFiltersChange: () => undefined, + onLoadMoreRuns: () => undefined, + onRunsFiltersChange: () => undefined, ...overrides, }; } describe("cron view", () => { - it("prompts to select a job before showing run history", () => { + it("shows all-job history mode by default", () => { const container = document.createElement("div"); render(renderCron(createProps()), container); - expect(container.textContent).toContain("Select a job to inspect run history."); + expect(container.textContent).toContain("Latest runs across all jobs."); + expect(container.textContent).toContain("Status"); + expect(container.textContent).toContain("All statuses"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("All delivery"); + expect(container.textContent).not.toContain("multi-select"); + }); + + it("toggles run status filter via dropdown checkboxes", () => { + const container = document.createElement("div"); + const onRunsFiltersChange = vi.fn(); + render( + renderCron( + createProps({ + onRunsFiltersChange, + }), + ), + container, + ); + + const statusOk = container.querySelector( + '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', + ); + expect(statusOk).not.toBeNull(); + if (!(statusOk instanceof HTMLInputElement)) { + return; + } + statusOk.checked = true; + statusOk.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); }); it("loads run history when clicking a job row", () => { @@ -80,6 +141,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", onLoadRuns, }), ), @@ -135,6 +197,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", runs: [ { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, { ts: 2, jobId: "job-1", status: "ok", summary: "newer run" }, @@ -159,6 +222,30 @@ describe("cron view", () => { expect(summaries[1]).toBe("older run"); }); + it("labels past nextRunAtMs as due instead of next", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + runsScope: "all", + runs: [ + { + ts: Date.now(), + jobId: "job-1", + status: "ok", + summary: "done", + nextRunAtMs: Date.now() - 13 * 60_000, + }, + ], + }), + ), + container, + ); + + expect(container.textContent).toContain("Due"); + expect(container.textContent).not.toContain("Next 13"); + }); + it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); render( @@ -198,7 +285,7 @@ describe("cron view", () => { expect(options).not.toContain("Announce summary (default)"); expect(options).toContain("Webhook POST"); expect(options).toContain("None (internal)"); - expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull(); + expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull(); }); it("shows webhook delivery details for jobs", () => { @@ -222,4 +309,346 @@ describe("cron view", () => { expect(container.textContent).toContain("webhook"); expect(container.textContent).toContain("https://example.invalid/cron"); }); + + it("wires the Edit action and shows save/cancel controls when editing", () => { + const container = document.createElement("div"); + const onEdit = vi.fn(); + const onLoadRuns = vi.fn(); + const onCancelEdit = vi.fn(); + const job = createJob("job-3"); + + render( + renderCron( + createProps({ + jobs: [job], + editingJobId: "job-3", + onEdit, + onLoadRuns, + onCancelEdit, + }), + ), + container, + ); + + const editButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Edit", + ); + expect(editButton).not.toBeUndefined(); + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onEdit).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-3"); + + expect(container.textContent).toContain("Edit Job"); + expect(container.textContent).toContain("Save changes"); + + const cancelButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Cancel", + ); + expect(cancelButton).not.toBeUndefined(); + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onCancelEdit).toHaveBeenCalledTimes(1); + }); + + it("renders advanced controls for cron + agent payload + delivery", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + deliveryMode: "announce", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Advanced"); + expect(container.textContent).toContain("Exact timing (no stagger)"); + expect(container.textContent).toContain("Stagger window"); + expect(container.textContent).toContain("Model"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).toContain("Best effort delivery"); + }); + + it("groups stagger window and unit inside the same stagger row", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + const staggerGroup = container.querySelector(".cron-stagger-group"); + expect(staggerGroup).not.toBeNull(); + expect(staggerGroup?.textContent).toContain("Stagger window"); + expect(staggerGroup?.textContent).toContain("Stagger unit"); + }); + + it("explains timeout blank behavior and shows cron jitter hint", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain( + "Optional. Leave blank to use the gateway default timeout behavior for this run.", + ); + expect(container.textContent).toContain("Need jitter? Use Advanced"); + }); + + it("disables Agent ID when clear-agent is enabled", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + clearAgent: true, + }, + }), + ), + container, + ); + + const agentInput = container.querySelector('input[placeholder="main or ops"]'); + expect(agentInput).not.toBeNull(); + expect(agentInput instanceof HTMLInputElement).toBe(true); + expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true); + }); + + it("renders sectioned cron form layout", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + expect(container.textContent).toContain("Enabled"); + expect(container.textContent).toContain("Jobs"); + expect(container.textContent).toContain("Next wake"); + expect(container.textContent).toContain("Basics"); + expect(container.textContent).toContain("Schedule"); + expect(container.textContent).toContain("Execution"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("Advanced"); + }); + + it("renders checkbox fields with input first for alignment", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + const checkboxLabel = container.querySelector(".cron-checkbox"); + expect(checkboxLabel).not.toBeNull(); + const firstElement = checkboxLabel?.firstElementChild; + expect(firstElement?.tagName.toLowerCase()).toBe("input"); + }); + + it("hides cron-only advanced controls for non-cron schedules", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + payloadKind: "systemEvent", + deliveryMode: "none", + }, + }), + ), + container, + ); + expect(container.textContent).not.toContain("Exact timing (no stagger)"); + expect(container.textContent).not.toContain("Stagger window"); + expect(container.textContent).not.toContain("Model"); + expect(container.textContent).not.toContain("Best effort delivery"); + }); + + it("renders inline validation errors and disables submit when invalid", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + name: "", + scheduleKind: "cron", + cronExpr: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + cronExpr: "Cron expression is required.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("Name is required."); + expect(container.textContent).toContain("Cron expression is required."); + expect(container.textContent).toContain("Agent message is required."); + expect(container.textContent).toContain("Can't add job yet"); + expect(container.textContent).toContain("Fix 3 fields to continue."); + + const saveButton = Array.from(container.querySelectorAll("button")).find((btn) => + ["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""), + ); + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + }); + + it("shows required legend and aria bindings for invalid required fields", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + name: "", + everyAmount: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + everyAmount: "Interval must be greater than 0.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("* Required"); + + const nameInput = container.querySelector("#cron-name"); + expect(nameInput?.getAttribute("aria-invalid")).toBe("true"); + expect(nameInput?.getAttribute("aria-describedby")).toBe("cron-error-name"); + expect(container.querySelector("#cron-error-name")?.textContent).toContain("Name is required."); + + const everyInput = container.querySelector("#cron-every-amount"); + expect(everyInput?.getAttribute("aria-invalid")).toBe("true"); + expect(everyInput?.getAttribute("aria-describedby")).toBe("cron-error-everyAmount"); + expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain( + "Interval must be greater than 0.", + ); + }); + + it("wires the Clone action from job rows", () => { + const container = document.createElement("div"); + const onClone = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-clone"); + render( + renderCron( + createProps({ + jobs: [job], + onClone, + onLoadRuns, + }), + ), + container, + ); + + const cloneButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Clone", + ); + expect(cloneButton).not.toBeUndefined(); + cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onClone).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-clone"); + }); + + it("selects row when clicking Enable/Disable, Run, and Remove actions", () => { + const container = document.createElement("div"); + const onToggle = vi.fn(); + const onRun = vi.fn(); + const onRemove = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-actions"); + render( + renderCron( + createProps({ + jobs: [job], + onToggle, + onRun, + onRemove, + onLoadRuns, + }), + ), + container, + ); + + const enableButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Disable", + ); + expect(enableButton).not.toBeUndefined(); + enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const runButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Run", + ); + expect(runButton).not.toBeUndefined(); + runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const removeButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Remove", + ); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onToggle).toHaveBeenCalledWith(job, false); + expect(onRun).toHaveBeenCalledWith(job); + expect(onRemove).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledTimes(3); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); + }); + + it("renders suggestion datalists for agent/model/thinking/timezone", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", payloadKind: "agentTurn" }, + agentSuggestions: ["main"], + modelSuggestions: ["openai/gpt-5.2"], + thinkingSuggestions: ["low"], + timezoneSuggestions: ["UTC"], + deliveryToSuggestions: ["+15551234567"], + }), + ), + container, + ); + + expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull(); + expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull(); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e5cc32408eae..e84c6f9f03fa 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,32 +1,119 @@ import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import type { CronFieldErrors, CronFieldKey } from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { + CronDeliveryStatus, + CronJobsEnabledFilter, + CronRunScope, + CronRunsStatusValue, + CronJobsSortBy, + CronRunsStatusFilter, + CronSortDir, +} from "../types.ts"; import type { CronFormState } from "../ui-types.ts"; export type CronProps = { basePath: string; loading: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsQuery: string; + jobsEnabledFilter: CronJobsEnabledFilter; + jobsSortBy: CronJobsSortBy; + jobsSortDir: CronSortDir; error: string | null; busy: boolean; form: CronFormState; + fieldErrors: CronFieldErrors; + canSubmit: boolean; + editingJobId: string | null; channels: string[]; channelLabels?: Record; channelMeta?: ChannelUiMetaEntry[]; runsJobId: string | null; runs: CronRunLogEntry[]; + runsTotal: number; + runsHasMore: boolean; + runsLoadingMore: boolean; + runsScope: CronRunScope; + runsStatuses: CronRunsStatusValue[]; + runsDeliveryStatuses: CronDeliveryStatus[]; + runsStatusFilter: CronRunsStatusFilter; + runsQuery: string; + runsSortDir: CronSortDir; + agentSuggestions: string[]; + modelSuggestions: string[]; + thinkingSuggestions: string[]; + timezoneSuggestions: string[]; + deliveryToSuggestions: string[]; onFormChange: (patch: Partial) => void; onRefresh: () => void; onAdd: () => void; + onEdit: (job: CronJob) => void; + onClone: (job: CronJob) => void; + onCancelEdit: () => void; onToggle: (job: CronJob, enabled: boolean) => void; onRun: (job: CronJob) => void; onRemove: (job: CronJob) => void; onLoadRuns: (jobId: string) => void; + onLoadMoreJobs: () => void; + onJobsFiltersChange: (patch: { + cronJobsQuery?: string; + cronJobsEnabledFilter?: CronJobsEnabledFilter; + cronJobsSortBy?: CronJobsSortBy; + cronJobsSortDir?: CronSortDir; + }) => void | Promise; + onLoadMoreRuns: () => void; + onRunsFiltersChange: (patch: { + cronRunsScope?: CronRunScope; + cronRunsStatuses?: CronRunsStatusValue[]; + cronRunsDeliveryStatuses?: CronDeliveryStatus[]; + cronRunsStatusFilter?: CronRunsStatusFilter; + cronRunsQuery?: string; + cronRunsSortDir?: CronSortDir; + }) => void | Promise; }; +const RUN_STATUS_OPTIONS: Array<{ value: CronRunsStatusValue; label: string }> = [ + { value: "ok", label: "OK" }, + { value: "error", label: "Error" }, + { value: "skipped", label: "Skipped" }, +]; + +const RUN_DELIVERY_OPTIONS: Array<{ value: CronDeliveryStatus; label: string }> = [ + { value: "delivered", label: "Delivered" }, + { value: "not-delivered", label: "Not delivered" }, + { value: "unknown", label: "Unknown" }, + { value: "not-requested", label: "Not requested" }, +]; + +function toggleSelection(selected: T[], value: T, checked: boolean): T[] { + const set = new Set(selected); + if (checked) { + set.add(value); + } else { + set.delete(value); + } + return Array.from(set); +} + +function summarizeSelection(selectedLabels: string[], allLabel: string) { + if (selectedLabels.length === 0) { + return allLabel; + } + if (selectedLabels.length <= 2) { + return selectedLabels.join(", "); + } + return `${selectedLabels[0]} +${selectedLabels.length - 1}`; +} + function buildChannelOptions(props: CronProps): string[] { const options = ["last", ...props.channels.filter(Boolean)]; const current = props.form.deliveryChannel?.trim(); @@ -54,291 +141,975 @@ function resolveChannelLabel(props: CronProps, channel: string): string { return props.channelLabels?.[channel] ?? channel; } +function renderRunFilterDropdown(params: { + id: string; + title: string; + summary: string; + options: Array<{ value: string; label: string }>; + selected: string[]; + onToggle: (value: string, checked: boolean) => void; + onClear: () => void; +}) { + return html` +
+ ${params.title} +
+ + ${params.summary} + +
+
+ ${params.options.map( + (option) => html` + + `, + )} +
+
+ +
+
+
+
+ `; +} + +function renderSuggestionList(id: string, options: string[]) { + const clean = Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))); + if (clean.length === 0) { + return nothing; + } + return html` + ${clean.map((value) => html` `)} + `; +} + +type BlockingField = { + key: CronFieldKey; + label: string; + message: string; + inputId: string; +}; + +function errorIdForField(key: CronFieldKey) { + return `cron-error-${key}`; +} + +function inputIdForField(key: CronFieldKey) { + if (key === "name") { + return "cron-name"; + } + if (key === "scheduleAt") { + return "cron-schedule-at"; + } + if (key === "everyAmount") { + return "cron-every-amount"; + } + if (key === "cronExpr") { + return "cron-cron-expr"; + } + if (key === "staggerAmount") { + return "cron-stagger-amount"; + } + if (key === "payloadText") { + return "cron-payload-text"; + } + if (key === "payloadModel") { + return "cron-payload-model"; + } + if (key === "payloadThinking") { + return "cron-payload-thinking"; + } + if (key === "timeoutSeconds") { + return "cron-timeout-seconds"; + } + return "cron-delivery-to"; +} + +function fieldLabelForKey( + key: CronFieldKey, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +) { + if (key === "payloadText") { + return form.payloadKind === "systemEvent" ? "Main timeline message" : "Assistant task prompt"; + } + if (key === "deliveryTo") { + return deliveryMode === "webhook" ? "Webhook URL" : "To"; + } + const labels: Record = { + name: "Name", + scheduleAt: "Run at", + everyAmount: "Every", + cronExpr: "Expression", + staggerAmount: "Stagger window", + payloadText: "Payload text", + payloadModel: "Model", + payloadThinking: "Thinking", + timeoutSeconds: "Timeout (seconds)", + deliveryTo: "To", + }; + return labels[key]; +} + +function collectBlockingFields( + errors: CronFieldErrors, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +): BlockingField[] { + const orderedKeys: CronFieldKey[] = [ + "name", + "scheduleAt", + "everyAmount", + "cronExpr", + "staggerAmount", + "payloadText", + "payloadModel", + "payloadThinking", + "timeoutSeconds", + "deliveryTo", + ]; + const fields: BlockingField[] = []; + for (const key of orderedKeys) { + const message = errors[key]; + if (!message) { + continue; + } + fields.push({ + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }); + } + return fields; +} + +function focusFormField(id: string) { + const el = document.getElementById(id); + if (!(el instanceof HTMLElement)) { + return; + } + if (typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + el.focus(); +} + +function renderFieldLabel(text: string, required = false) { + return html` + ${text} + ${ + required + ? html` + + required + ` + : nothing + } + `; +} + export function renderCron(props: CronProps) { + const isEditing = Boolean(props.editingJobId); + const isAgentTurn = props.form.payloadKind === "agentTurn"; + const isCronSchedule = props.form.scheduleKind === "cron"; const channelOptions = buildChannelOptions(props); const selectedJob = props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); - const selectedRunTitle = selectedJob?.name ?? props.runsJobId ?? "(select a job)"; - const orderedRuns = props.runs.toSorted((a, b) => b.ts - a.ts); + const selectedRunTitle = + props.runsScope === "all" + ? "all jobs" + : (selectedJob?.name ?? props.runsJobId ?? "(select a job)"); + const runs = props.runs; + const selectedStatusLabels = RUN_STATUS_OPTIONS.filter((option) => + props.runsStatuses.includes(option.value), + ).map((option) => option.label); + const selectedDeliveryLabels = RUN_DELIVERY_OPTIONS.filter((option) => + props.runsDeliveryStatuses.includes(option.value), + ).map((option) => option.label); + const statusSummary = summarizeSelection(selectedStatusLabels, "All statuses"); + const deliverySummary = summarizeSelection(selectedDeliveryLabels, "All delivery"); const supportsAnnounce = props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; + const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); + const blockedByValidation = !props.busy && blockingFields.length > 0; + const submitDisabledReason = + blockedByValidation && !props.canSubmit + ? `Fix ${blockingFields.length} ${blockingFields.length === 1 ? "field" : "fields"} to continue.` + : ""; return html` -
-
-
Scheduler
-
Gateway-owned cron scheduler status.
-
-
-
Enabled
-
+
+
+
+
Enabled
+
+ ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} -
-
-
-
Jobs
-
${props.status?.jobs ?? "n/a"}
-
-
-
Next wake
-
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
-
- - ${props.error ? html`${props.error}` : nothing} +
+
Jobs
+
${props.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
+ + ${props.error ? html`${props.error}` : nothing} +
+
-
-
New Job
-
Create a scheduled wakeup or agent run.
-
- - - - - -
- ${renderScheduleFields(props)} -
- - - -
- -
- +
+
+
+
+
+
Jobs
+
All scheduled jobs stored in the gateway.
+
+
${props.jobs.length} shown of ${props.jobsTotal}
+
+
+ + + + +
+ ${ + props.jobs.length === 0 + ? html` +
No matching jobs.
+ ` + : html` +
+ ${props.jobs.map((job) => renderJob(job, props))} +
+ ` + } ${ - props.form.payloadKind === "agentTurn" + props.jobsHasMore ? html` - +
+ +
` : nothing } +
+ +
+
+
+
Run history
+
+ ${ + props.runsScope === "all" + ? "Latest runs across all jobs." + : `Latest runs for ${selectedRunTitle}.` + } +
+
+
${runs.length} shown of ${props.runsTotal}
+
+
+
+ + + +
+
+ ${renderRunFilterDropdown({ + id: "status", + title: "Status", + summary: statusSummary, + options: RUN_STATUS_OPTIONS, + selected: props.runsStatuses, + onToggle: (value, checked) => { + const next = toggleSelection( + props.runsStatuses, + value as CronRunsStatusValue, + checked, + ); + void props.onRunsFiltersChange({ cronRunsStatuses: next }); + }, + onClear: () => { + void props.onRunsFiltersChange({ cronRunsStatuses: [] }); + }, + })} + ${renderRunFilterDropdown({ + id: "delivery", + title: "Delivery", + summary: deliverySummary, + options: RUN_DELIVERY_OPTIONS, + selected: props.runsDeliveryStatuses, + onToggle: (value, checked) => { + const next = toggleSelection( + props.runsDeliveryStatuses, + value as CronDeliveryStatus, + checked, + ); + void props.onRunsFiltersChange({ cronRunsDeliveryStatuses: next }); + }, + onClear: () => { + void props.onRunsFiltersChange({ cronRunsDeliveryStatuses: [] }); + }, + })} +
+
${ - selectedDeliveryMode !== "none" + props.runsScope === "job" && props.runsJobId == null ? html` - +
Select a job to inspect run history.
+ ` + : runs.length === 0 + ? html` +
No matching runs.
+ ` + : html` +
+ ${runs.map((entry) => renderRun(entry, props.basePath))} +
+ ` + } + ${ + (props.runsScope === "all" || props.runsJobId != null) && props.runsHasMore + ? html` +
+ +
+ ` + : nothing + } +
+
+ +
+
${isEditing ? "Edit Job" : "New Job"}
+
+ ${isEditing ? "Update the selected scheduled job." : "Create a scheduled wakeup or agent run."} +
+
+
+ Required +
+
+
Basics
+
Name it, choose the assistant, and set enabled state.
+
+ + + + +
+
+ +
+
Schedule
+
Control when this job runs.
+
+ +
+ ${renderScheduleFields(props)} +
+ +
+
Execution
+
Choose when to wake, and what this job should do.
+
+ + + + ${ + isAgentTurn + ? html` + + ` + : nothing + } +
+ +
+ +
+
Delivery
+
Choose where run summaries are sent.
+
+ + ` : nothing } + + + +
Announce posts a summary to chat. None keeps execution internal.
+ + ${ + selectedDeliveryMode !== "none" + ? html` + + ${ + selectedDeliveryMode === "announce" + ? html` + + ` + : nothing + } + ${ + selectedDeliveryMode === "webhook" + ? renderFieldError( + props.fieldErrors.deliveryTo, + errorIdForField("deliveryTo"), + ) + : nothing + } + ` + : nothing + } +
+
+ +
+ Advanced +
+ Optional overrides for delivery guarantees, schedule jitter, and model controls. +
+
+ + + ${ + isCronSchedule + ? html` + +
+ + +
+ ` + : nothing + } + ${ + isAgentTurn + ? html` + + + ` + : nothing + } + ${ + selectedDeliveryMode !== "none" + ? html` + + ` + : nothing + } +
+
+
+ ${ + blockedByValidation + ? html` +
+
Can't add job yet
+
Fill the required fields below to enable submit.
+
    + ${blockingFields.map( + (field) => html` +
  • + +
  • + `, + )} +
+
+ ` + : nothing + } +
+ + ${ + submitDisabledReason + ? html`
${submitDisabledReason}
` + : nothing + } + ${ + isEditing + ? html` + ` : nothing }
-
- -
-
+
-
-
Jobs
-
All scheduled jobs stored in the gateway.
- ${ - props.jobs.length === 0 - ? html` -
No jobs yet.
- ` - : html` -
- ${props.jobs.map((job) => renderJob(job, props))} -
- ` - } -
- -
-
Run history
-
Latest runs for ${selectedRunTitle}.
- ${ - props.runsJobId == null - ? html` -
Select a job to inspect run history.
- ` - : orderedRuns.length === 0 - ? html` -
No runs yet.
- ` - : html` -
- ${orderedRuns.map((entry) => renderRun(entry, props.basePath))} -
- ` - } -
+ ${renderSuggestionList("cron-agent-suggestions", props.agentSuggestions)} + ${renderSuggestionList("cron-model-suggestions", props.modelSuggestions)} + ${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)} + ${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)} + ${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)} `; } @@ -346,31 +1117,44 @@ function renderScheduleFields(props: CronProps) { const form = props.form; if (form.scheduleKind === "at") { return html` -