diff --git a/.agents/skills/PR_WORKFLOW.md b/.agents/skills/PR_WORKFLOW.md
index 402dc42f1c8..40306507355 100644
--- a/.agents/skills/PR_WORKFLOW.md
+++ b/.agents/skills/PR_WORKFLOW.md
@@ -107,7 +107,7 @@ Before any substantive review or prep work, **always rebase the PR branch onto c
- In normal `prepare-pr` runs, commits are created via `scripts/committer "" `. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
-- During `prepare-pr`, use this commit subject format: `fix: (openclaw#) thanks @`.
+- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#) thanks @` for the final merge/squash commit.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md
index ae89b1a2742..041e79a6768 100644
--- a/.agents/skills/merge-pr/SKILL.md
+++ b/.agents/skills/merge-pr/SKILL.md
@@ -19,6 +19,7 @@ Merge a prepared PR only after deterministic validation.
- Never use `gh pr merge --auto` in this flow.
- Never run `git push` directly.
- Require `--match-head-commit` during merge.
+- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract
diff --git a/.agents/skills/prepare-pr/SKILL.md b/.agents/skills/prepare-pr/SKILL.md
index 95252ef0615..462e5bc2bd4 100644
--- a/.agents/skills/prepare-pr/SKILL.md
+++ b/.agents/skills/prepare-pr/SKILL.md
@@ -34,7 +34,7 @@ scripts/pr-prepare init
- `.local/review.json` is mandatory.
- Resolve all `BLOCKER` and `IMPORTANT` items.
-3. Commit with required subject format and validate it.
+3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit).
4. Run gates via wrapper.
@@ -76,21 +76,12 @@ jq -r '.docs' .local/review.json
4. Commit scoped changes
-Required commit subject format:
-
-- `fix: (openclaw#) thanks @`
+Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks.
Use explicit file list:
```sh
-source .local/pr-meta.env
-scripts/committer "fix: (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" ...
-```
-
-Validate commit subject:
-
-```sh
-scripts/pr-prepare validate-commit
+scripts/committer "fix: " ...
```
5. Run gates
diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md
index ab9d75d967f..f5694ca2c41 100644
--- a/.agents/skills/review-pr/SKILL.md
+++ b/.agents/skills/review-pr/SKILL.md
@@ -18,6 +18,7 @@ Perform a read-only review and produce both human and machine-readable outputs.
- Never push, merge, or modify code intended to keep.
- Work only in `.worktrees/pr-`.
+- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 82b560c473d..00000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-name: Bug report
-about: Report a problem or unexpected behavior in Clawdbot.
-title: "[Bug]: "
-labels: bug
----
-
-## Summary
-
-What went wrong?
-
-## Steps to reproduce
-
-1.
-2.
-3.
-
-## Expected behavior
-
-What did you expect to happen?
-
-## Actual behavior
-
-What actually happened?
-
-## Environment
-
-- Clawdbot version:
-- OS:
-- Install method (pnpm/npx/docker/etc):
-
-## Logs or screenshots
-
-Paste relevant logs or add screenshots (redact secrets).
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000000..56a343c38d8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,95 @@
+name: Bug report
+description: Report a defect or unexpected behavior in OpenClaw.
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: One-sentence statement of what is broken.
+ placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
+ validations:
+ required: true
+ - type: textarea
+ id: repro
+ attributes:
+ label: Steps to reproduce
+ description: Provide the shortest deterministic repro path.
+ placeholder: |
+ 1. Configure channel X.
+ 2. Send message Y.
+ 3. Run command Z.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: What should happen if the bug does not exist.
+ placeholder: Agent posts a reply in the same thread.
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ description: What happened instead, including user-visible errors.
+ placeholder: No reply is posted; gateway logs "reply target not found".
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: OpenClaw version
+ description: Exact version/build tested.
+ placeholder: 2026.2.13
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Operating system
+ description: OS and version where this occurs.
+ placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
+ validations:
+ required: true
+ - type: input
+ id: install_method
+ attributes:
+ label: Install method
+ description: How OpenClaw was installed or launched.
+ placeholder: npm global / pnpm dev / docker / mac app
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs, screenshots, and evidence
+ description: Include redacted logs/screenshots/recordings that prove the behavior.
+ render: shell
+ - type: textarea
+ id: impact
+ attributes:
+ label: Impact and severity
+ description: |
+ Explain who is affected, how severe it is, how often it happens, and the practical consequence.
+ Include:
+ - Affected users/systems/channels
+ - Severity (annoying, blocks workflow, data risk, etc.)
+ - Frequency (always/intermittent/edge case)
+ - Consequence (missed messages, failed onboarding, extra cost, etc.)
+ placeholder: |
+ Affected: Telegram group users on 2026.2.13
+ Severity: High (blocks replies)
+ Frequency: 100% repro
+ Consequence: Agents cannot respond in threads
+ - type: textarea
+ id: additional_information
+ attributes:
+ label: Additional information
+ description: Add any context that helps triage but does not fit above.
+ placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 7b33641dc13..00000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-name: Feature request
-about: Suggest an idea or improvement for Clawdbot.
-title: "[Feature]: "
-labels: enhancement
----
-
-## Summary
-
-Describe the problem you are trying to solve or the opportunity you see.
-
-## Proposed solution
-
-What would you like Clawdbot to do?
-
-## Alternatives considered
-
-Any other approaches you have considered?
-
-## Additional context
-
-Links, screenshots, or related issues.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000000..3594b73a2c5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,70 @@
+name: Feature request
+description: Propose a new capability or product improvement.
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Help us evaluate this request with concrete use cases and tradeoffs.
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: One-line statement of the requested capability.
+ placeholder: Add per-channel default response prefix.
+ validations:
+ required: true
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem to solve
+ description: What user pain this solves and why current behavior is insufficient.
+ placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
+ validations:
+ required: true
+ - type: textarea
+ id: proposed_solution
+ attributes:
+ label: Proposed solution
+ description: Desired behavior/API/UX with as much specificity as possible.
+ placeholder: Support channels..responsePrefix with default fallback and account-level override.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ description: Other approaches considered and why they are weaker.
+ placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
+ - type: textarea
+ id: impact
+ attributes:
+ label: Impact
+ description: |
+ Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
+ Include:
+ - Affected users/systems/channels
+ - Severity (annoying, blocks workflow, etc.)
+ - Frequency (always/intermittent/edge case)
+ - Consequence (delays, errors, extra manual work, etc.)
+ placeholder: |
+ Affected: Multi-team shared channels
+ Severity: Medium
+ Frequency: Daily
+ Consequence: +20 minutes/day/operator and delayed alerts
+ validations:
+ required: true
+ - type: textarea
+ id: evidence
+ attributes:
+ label: Evidence/examples
+ description: Prior art, links, screenshots, logs, or metrics.
+ placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
+ - type: textarea
+ id: additional_information
+ attributes:
+ label: Additional information
+ description: Extra context, constraints, or references not covered above.
+ placeholder: Must remain backward-compatible with existing config keys.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000000..9b0e7f8dc4b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,108 @@
+## Summary
+
+Describe the problem and fix in 2–5 bullets:
+
+- Problem:
+- Why it matters:
+- What changed:
+- What did NOT change (scope boundary):
+
+## Change Type (select all)
+
+- [ ] Bug fix
+- [ ] Feature
+- [ ] Refactor
+- [ ] Docs
+- [ ] Security hardening
+- [ ] Chore/infra
+
+## Scope (select all touched areas)
+
+- [ ] Gateway / orchestration
+- [ ] Skills / tool execution
+- [ ] Auth / tokens
+- [ ] Memory / storage
+- [ ] Integrations
+- [ ] API / contracts
+- [ ] UI / DX
+- [ ] CI/CD / infra
+
+## Linked Issue/PR
+
+- Closes #
+- Related #
+
+## User-visible / Behavior Changes
+
+List user-visible changes (including defaults/config).
+If none, write `None`.
+
+## Security Impact (required)
+
+- New permissions/capabilities? (`Yes/No`)
+- Secrets/tokens handling changed? (`Yes/No`)
+- New/changed network calls? (`Yes/No`)
+- Command/tool execution surface changed? (`Yes/No`)
+- Data access scope changed? (`Yes/No`)
+- If any `Yes`, explain risk + mitigation:
+
+## Repro + Verification
+
+### Environment
+
+- OS:
+- Runtime/container:
+- Model/provider:
+- Integration/channel (if any):
+- Relevant config (redacted):
+
+### Steps
+
+1.
+2.
+3.
+
+### Expected
+
+-
+
+### Actual
+
+-
+
+## Evidence
+
+Attach at least one:
+
+- [ ] Failing test/log before + passing after
+- [ ] Trace/log snippets
+- [ ] Screenshot/recording
+- [ ] Perf numbers (if relevant)
+
+## Human Verification (required)
+
+What you personally verified (not just CI), and how:
+
+- Verified scenarios:
+- Edge cases checked:
+- What you did **not** verify:
+
+## Compatibility / Migration
+
+- Backward compatible? (`Yes/No`)
+- Config/env changes? (`Yes/No`)
+- Migration needed? (`Yes/No`)
+- If yes, exact upgrade steps:
+
+## Failure Recovery (if this breaks)
+
+- How to disable/revert this change quickly:
+- Files/config to restore:
+- Known bad symptoms reviewers should watch for:
+
+## Risks and Mitigations
+
+List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
+
+- Risk:
+ - Mitigation:
diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml
index c43df1e4062..e3987c500c3 100644
--- a/.github/workflows/auto-response.yml
+++ b/.github/workflows/auto-response.yml
@@ -89,7 +89,8 @@ jobs:
}
}
- if (!hasTriggerLabel) {
+ const isLabelEvent = context.payload.action === "labeled";
+ if (!hasTriggerLabel && !isLabelEvent) {
return;
}
@@ -130,15 +131,19 @@ jobs:
}
}
+ const invalidLabel = "invalid";
+ const dirtyLabel = "dirty";
+ const noisyPrMessage =
+ "Closing this PR because it looks dirty (too many unrelated commits). Please recreate the PR from a clean branch.";
+
const pullRequest = context.payload.pull_request;
if (pullRequest) {
- const labelCount = labelSet.size;
- if (labelCount > 20) {
+ if (labelSet.has(dirtyLabel)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
- body: "Closing this PR because it has more than 20 labels, which usually means the branch is too noisy. Please recreate the PR from a clean branch.",
+ body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
@@ -148,6 +153,42 @@ jobs:
});
return;
}
+ const labelCount = labelSet.size;
+ if (labelCount > 20) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ body: noisyPrMessage,
+ });
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ state: "closed",
+ });
+ return;
+ }
+ if (labelSet.has(invalidLabel)) {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pullRequest.number,
+ state: "closed",
+ });
+ return;
+ }
+ }
+
+ if (issue && labelSet.has(invalidLabel)) {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ state: "closed",
+ state_reason: "not_planned",
+ });
+ return;
}
const rule = rules.find((item) => labelSet.has(item.label));
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f69c7ae2698..5e8a797ce74 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,14 +6,14 @@ on:
pull_request:
concurrency:
- group: ci-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: true
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
- runs-on: ubuntu-latest
+ runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
@@ -33,7 +33,7 @@ jobs:
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
- runs-on: ubuntu-latest
+ runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
@@ -204,6 +204,14 @@ jobs:
if: matrix.task == 'test' && matrix.runtime == 'node'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
+ - name: Configure Node test resources
+ if: matrix.task == 'test' && matrix.runtime == 'node'
+ run: |
+ # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
+ # Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
+ echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
+ echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
+
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
@@ -664,7 +672,8 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
- java-version: 21
+ # setup-android's sdkmanager currently crashes on JDK 21 in CI.
+ java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index a286026ae32..05e63005dd5 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -13,6 +13,10 @@ on:
- ".agents/**"
- "skills/**"
+concurrency:
+ group: docker-release-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
diff --git a/.github/workflows/formal-conformance.yml b/.github/workflows/formal-conformance.yml
index a8ec86bfce7..8ba6d7e56b8 100644
--- a/.github/workflows/formal-conformance.yml
+++ b/.github/workflows/formal-conformance.yml
@@ -108,6 +108,7 @@ jobs:
- name: Comment on PR (informational)
if: steps.drift.outputs.drift == 'true'
+ continue-on-error: true
uses: actions/github-script@v7
with:
script: |
diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
index e6c0914f018..45154a5fab4 100644
--- a/.github/workflows/install-smoke.yml
+++ b/.github/workflows/install-smoke.yml
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
concurrency:
- group: install-smoke-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: true
+ group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
docs-scope:
@@ -33,19 +33,17 @@ jobs:
- name: Checkout CLI
uses: actions/checkout@v4
- - name: Setup pnpm (corepack retry)
- run: |
- set -euo pipefail
- corepack enable
- for attempt in 1 2 3; do
- if corepack prepare pnpm@10.23.0 --activate; then
- pnpm -v
- exit 0
- fi
- echo "corepack prepare failed (attempt $attempt/3). Retrying..."
- sleep $((attempt * 10))
- done
- exit 1
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ check-latest: true
+
+ - name: Setup pnpm + cache store
+ uses: ./.github/actions/setup-pnpm-store-cache
+ with:
+ pnpm-version: "10.23.0"
+ cache-key-suffix: "node22"
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml
new file mode 100644
index 00000000000..c92a05c3aeb
--- /dev/null
+++ b/.github/workflows/sandbox-common-smoke.yml
@@ -0,0 +1,56 @@
+name: Sandbox Common Smoke
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - Dockerfile.sandbox
+ - Dockerfile.sandbox-common
+ - scripts/sandbox-common-setup.sh
+ pull_request:
+ paths:
+ - Dockerfile.sandbox
+ - Dockerfile.sandbox-common
+ - scripts/sandbox-common-setup.sh
+
+concurrency:
+ group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ sandbox-common-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: false
+
+ - name: Build minimal sandbox base (USER sandbox)
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
+ FROM debian:bookworm-slim
+ RUN useradd --create-home --shell /bin/bash sandbox
+ USER sandbox
+ WORKDIR /home/sandbox
+ EOF
+
+ - name: Build sandbox-common image (root for installs, sandbox at runtime)
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
+ TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
+ PACKAGES="ca-certificates" \
+ INSTALL_PNPM=0 \
+ INSTALL_BUN=0 \
+ INSTALL_BREW=0 \
+ FINAL_USER=sandbox \
+ scripts/sandbox-common-setup.sh
+
+ u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
+ test "$u" = "sandbox"
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index ccafcf01a18..4c81828316d 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -31,7 +31,7 @@ jobs:
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
- operations-per-run: 500
+ operations-per-run: 10000
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |
diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml
index 14fe6ae429f..438a71162da 100644
--- a/.github/workflows/workflow-sanity.yml
+++ b/.github/workflows/workflow-sanity.yml
@@ -6,8 +6,8 @@ on:
branches: [main]
concurrency:
- group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: true
+ group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
no-tabs:
diff --git a/.gitignore b/.gitignore
index 55f905293cf..ea7f13ee132 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,8 @@ apps/android/.cxx/
*.bun-build
apps/macos/.build/
apps/shared/MoltbotKit/.build/
+apps/shared/OpenClawKit/.build/
+apps/shared/OpenClawKit/Package.resolved
**/ModuleCache/
bin/
bin/clawdbot-mac
@@ -82,4 +84,5 @@ USER.md
/memory/
.agent/*.json
!.agent/workflows/
-local/
+/local/
+package-lock.json
diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc
index f7208b4da3d..41a8ca9d543 100644
--- a/.oxfmtrc.jsonc
+++ b/.oxfmtrc.jsonc
@@ -14,6 +14,7 @@
"node_modules/",
"patches/",
"pnpm-lock.yaml/",
+ "src/auto-reply/reply/export-html/",
"Swabble/",
"vendor/",
],
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 4097a58f2d5..687b5bb5eb5 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -11,6 +11,8 @@
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-new": "off",
+ "eslint/no-shadow": "off",
+ "eslint/no-unmodified-loop-condition": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
@@ -27,8 +29,9 @@
"extensions/",
"node_modules/",
"patches/",
- "pnpm-lock.yaml/",
+ "pnpm-lock.yaml",
"skills/",
+ "src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"Swabble/",
"vendor/"
diff --git a/.pi/prompts/landpr.md b/.pi/prompts/landpr.md
index 1b150c05e0d..95e4692f3e5 100644
--- a/.pi/prompts/landpr.md
+++ b/.pi/prompts/landpr.md
@@ -42,8 +42,9 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
- If unclear, ask
10. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
-11. Commit via committer (include # + contributor in commit message):
- - `committer "fix: (#) (thanks @$contrib)" CHANGELOG.md `
+11. Commit via committer (final merge commit only includes PR # + thanks):
+ - For the final merge-ready commit: `committer "fix: (#) (thanks @$contrib)" CHANGELOG.md `
+ - If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
- `land_sha=$(git rev-parse HEAD)`
12. Push updated PR branch (rebase => usually needs force):
diff --git a/AGENTS.md b/AGENTS.md
index a64073877b5..e7c4bc9f31f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,6 +52,7 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
+- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `.
@@ -69,6 +70,10 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
+- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
+- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
+- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
+- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
@@ -99,8 +104,8 @@
- Create commits with `scripts/committer "" `; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
-- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
-- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
+- PR submission template (canonical): `.github/pull_request_template.md`
+- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
## Shorthand Commands
@@ -118,6 +123,19 @@
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
+## GHSA (Repo Advisory) Patch/Publish
+
+- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/`
+- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
+- Private fork PRs must be closed:
+ `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)`
+ `gh pr list -R "$fork" --state open` (must be empty)
+- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
+- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
+- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
+- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
+- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
+
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
@@ -181,3 +199,39 @@
- Publish: `npm publish --access public --otp=""` (run from the package dir).
- Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
+
+## Plugin Release Fast Path (no core `openclaw` publish)
+
+- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
+- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
+ - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
+ - `eval "$(op signin --account my.1password.com)"`
+- 1Password helpers:
+ - password used by `npm login`:
+ `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
+ - OTP:
+ `op read 'op://Private/Npmjs/one-time password?attribute=otp'`
+- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
+ - compare local plugin `version` to `npm view version`
+ - only run `npm publish --access public --otp=""` when versions differ
+ - skip if package is missing on npm or version already matches.
+- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
+- Post-check for each release:
+ - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.16`
+ - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
+
+## Changelog Release Notes
+
+- When cutting a mac release with beta GitHub prerelease:
+ - Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
+ - Create prerelease with title `openclaw YYYY.M.D-beta.N`.
+ - Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
+ - Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
+
+- Keep top version entries in `CHANGELOG.md` sorted by impact:
+ - `### Changes` first.
+ - `### Fixes` deduped and ranked with user-facing fixes first.
+- Before tagging/publishing, run:
+ - `node --import tsx scripts/release-check.ts`
+ - `pnpm release:check`
+ - `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6c314ee9a1..54e40285084 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,45 +2,413 @@
Docs: https://docs.openclaw.ai
-## 2026.2.13 (Unreleased)
+## 2026.2.16 (Unreleased)
### Changes
-- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
+- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
+- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
+- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
+- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
+- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow.
+- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
+- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
+- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow.
+- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449)
### Fixes
-- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
-- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
-- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
-- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
-- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
-- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
-- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
-- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
-- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
-- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
+- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source.
+- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete.
+- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505)
+- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin.
+- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin.
+- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507)
+- Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931.
+- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
+- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
+- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u.
+- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
+- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
+- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
+- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
+- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
+- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
+- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
+- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman.
+- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494)
+- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight.
+- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270)
+- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
+- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
+- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
+- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
+- Telegram: ignore `` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang.
+- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise.
+- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
+- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679)
+- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT.
+- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
+- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
+- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502)
+- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544)
+- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation.
+- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931.
+- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437)
+- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior.
+- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836)
+- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc.
+- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
+- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim.
+- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
+- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel.
+- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615)
+- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately.
+- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
+- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
+- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
+- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow.
+- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
+- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538)
+- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
+- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow.
+- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777.
+- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602)
+
+## 2026.2.15
+
+### Changes
+
+- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
+- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
+- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
+- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
+- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
+- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
+- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
+- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz.
+- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz.
+
+### Fixes
+
+- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x.
+- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
+- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
+- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
+- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
+- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
+- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
+- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
+- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
+- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
+- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
+- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
+- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
+- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
+- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
+- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
+- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv.
+- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
+- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
+- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
+- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
+- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
+- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
+- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
+- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
+- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan.
+- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
+- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
+- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
+- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
+- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
+- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
+- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
+- Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
+- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
+- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
+- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
+- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
+- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
+- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
+- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
+- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
+- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
+- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
+- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
+- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
+- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
+- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
+- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
+- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
+
+## 2026.2.14
+
+### Changes
+
+- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
+- Slack/Discord: add `dmPolicy` + `allowFrom` config aliases for DM access control; legacy `dm.policy` + `dm.allowFrom` keys remain supported and `openclaw doctor --fix` can migrate them.
+- Discord: allow exec approval prompts to target channels or both DM+channel via `channels.discord.execApprovals.target`. (#16051) Thanks @leonnardo.
+- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
+- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr.
+- Agents: add optional `messages.suppressToolErrors` config to hide non-mutating tool-failure warnings from user-facing chat while still surfacing mutating failures. (#16620) Thanks @vai-oro.
+
+### Fixes
+
+- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent.
+- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
+- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
+- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
+- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
+- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
+- Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
+- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
+- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
+- Media understanding: treat binary `application/vnd.*`/zip/octet-stream attachments as non-text (while keeping vendor `+json`/`+xml` text-eligible) so Office/ZIP files are not inlined into prompt body text. (#16513) Thanks @rmramsey32.
+- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
+- Auto-reply/Block streaming: strip leading whitespace from streamed block replies so messages starting with blank lines no longer deliver visible leading empty lines. (#16422) Thanks @mcinteerj.
+- Auto-reply/Queue: keep queued followups and overflow summaries when drain attempts fail, then retry delivery instead of dropping messages on transient errors. (#16771) Thanks @mmhzlrj.
+- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
+- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722)
+- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
+- CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command.
+- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
+- TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
+- TUI: honor explicit `--session ` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
+- TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
+- TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
+- TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
+- TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
+- TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
+- TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
+- TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
+- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat.
+- Gateway/Agent: route bare `/new` and `/reset` through `sessions.reset` before running the fresh-session greeting prompt, so reset commands clear the current session in-place instead of falling through to normal agent runs. (#16732) Thanks @kdotndot and @vignesh07.
+- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
+- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750)
+- Cron: treat persisted jobs with missing `enabled` as enabled by default across update/list/timer due-path checks, and add regression coverage for missing-`enabled` store records. (#15433) Thanks @eternauta1337.
+- Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale `runningAtMs` markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
+- Heartbeat/Cron: treat cron-tagged queued system events as cron reminders even on interval wakes, so isolated cron announce summaries no longer run under the default heartbeat prompt. (#14947) Thanks @archedark-ada and @vignesh07.
+- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow.
+- Discord: treat empty per-guild `channels: {}` config maps as no channel allowlist (not deny-all), so `groupPolicy: "open"` guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
+- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
+- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
+- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient.
+- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi.
+- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto.
+- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
+- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
+- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
+- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729)
+- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
+- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
+- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
+- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
+- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
+- Agents: treat empty-stream provider failures (`request ended without sending any chunks`) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
+- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
+- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades.
+- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
+- Ollama/Agents: avoid forcing `` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
+- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
+- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
+- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
+- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks.
+- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
+- Memory/Builtin: keep `memory status` dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
+- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output.
+- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
+- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
+- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier.
+- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads.
+- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
+- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`.
+- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
+- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c ` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai.
+- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96.
+- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao.
+- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
+- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
+- Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
+- Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
+- Security/Memory-LanceDB: require explicit `autoCapture: true` opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
+- Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
+- Gateway/Memory: clean up `agentRunSeq` tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
+- Auto-reply/Memory: bound `ABORT_MEMORY` growth by evicting oldest entries and deleting reset (`false`) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
+- Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
+- Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
+- Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
+- Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
+- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko.
+- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
+- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
+- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420.
+- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras.
+- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
+- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
+- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
+- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
+- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
+- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
+- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
+- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
+- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra.
+- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
+- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
+- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
+- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
+- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
+- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
+- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan.
+- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
+- Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
+- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
+- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
+- Telegram/Security: reject Telegram webhook startup when `webhookSecret` is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
+- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
+- Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
+- Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit.
+- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
+- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
+- Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
+- Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec.
+- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
+- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
+- Scripts/Security: validate GitHub logins and avoid shell invocation in `scripts/update-clawtributors.ts` to prevent command injection via malicious commit records. Thanks @scanleale.
+- Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
+- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec.
+- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
+- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
+- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
+- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
+- Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
+- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
+- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
+- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
+- Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
+- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec.
+- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec.
+- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
+- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
+
+## 2026.2.13
+
+### Changes
+
+- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy.
+- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
+- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
+- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
+- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21.
+- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
+- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
+- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
+- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
+
+### Breaking
+
+- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
+
+### Fixes
+
+- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
+- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
+- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
+- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
+- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker.
+- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
+- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
+- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
+- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
-- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
-- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
-- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
-- Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
+- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
+- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189)
+- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
+- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
+- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
+- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
+- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.
+- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk.
+- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
+- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599)
+- Discord: avoid misrouting numeric guild allowlist entries to `/channels/` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
+- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago.
+- Memory/LanceDB: add configurable `captureMaxChars` for auto-capture while keeping the legacy 500-char default. (#16641) Thanks @ciberponk.
+- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
+- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
+- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
+- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
+- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
+- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
- Agents/Codex: allow `gpt-5.3-codex-spark` in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.
-- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
-- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
- Models/Codex: resolve configured `openai-codex/gpt-5.3-codex-spark` through forward-compat fallback during `models list`, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.
+- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
+- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
+- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
+- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc.
+- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
-- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
+- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
+- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
-- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
-- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
-- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
+- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
+- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
+- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
+- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
-- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
-- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
+- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
+- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
+- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
+- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes.
+- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
+- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
+- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
+- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
+- Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent.
+- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
+- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
+- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
+- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
+- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
+- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax.
+- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface).
+- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
+- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
+- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
+- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
+- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
+- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
+- Security/Gateway: bind node `system.run` approval overrides to gateway exec-approval records (runId-bound), preventing approval-bypass via `node.invoke` param injection. Thanks @222n5.
+- Agents/Nodes: harden node exec approval decision handling in the `nodes` tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.
+- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
+- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
+- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
+- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
+- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
+- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
+- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998)
+- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
+- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
+- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
+- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
+- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
+- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
+- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale.
+- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
+- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
+- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
+- Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.
+- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise.
+- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
+- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
+- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic.
+- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
+- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c.
+- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
+- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
## 2026.2.12
@@ -60,6 +428,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
+- Sessions: guard `withSessionStoreLock` against undefined `storePath` to prevent `path.dirname` crash. (#14717)
- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
@@ -77,11 +446,13 @@ Docs: https://docs.openclaw.ai
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
+- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context.
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
+- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber.
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug.
@@ -94,20 +465,24 @@ Docs: https://docs.openclaw.ai
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
+- Slack: honor `limit` for `emoji-list` actions across core and extension adapters, with capped emoji-list responses in the Slack action handler. (#4293) Thanks @mcaxtr.
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999.
- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.
- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
+- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow.
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
+- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07.
- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
- Voice Call: pass Twilio stream auth token via `` instead of query string. (#14029) Thanks @mcwigglesmcgee.
+- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl.
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
@@ -133,6 +508,7 @@ Docs: https://docs.openclaw.ai
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
+- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238.
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
@@ -144,6 +520,7 @@ Docs: https://docs.openclaw.ai
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
+- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302)
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
@@ -207,6 +584,7 @@ Docs: https://docs.openclaw.ai
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
+- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow.
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
@@ -218,6 +596,10 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
+- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
+- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
+- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123.
+- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty.
## 2026.2.6
@@ -281,6 +663,18 @@ Docs: https://docs.openclaw.ai
### Fixes
+- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua.
+- Update: remove dead restore control-ui step that failed on gitignored dist/ output.
+- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras.
+- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke.
+- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70.
+- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672)
+- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
+- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
+- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
+- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
+- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
+- Exec approvals: ensure two-phase approval registration/decision flow works reliably by validating `twoPhase` requests and exposing `waitDecision` as an approvals-scoped gateway method. (#3357, fixes #2402) Thanks @ramin-shirali.
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
@@ -351,11 +745,13 @@ Docs: https://docs.openclaw.ai
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
-- Security: require validated shared-secret auth before skipping device identity on gateway connect.
+- Security: require validated shared-secret auth before skipping device identity on gateway connect. Thanks @simecek.
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
+- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
-- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
+- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
+- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
@@ -391,7 +787,7 @@ Docs: https://docs.openclaw.ai
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
-- Plugins: validate plugin/hook install paths and reject traversal-like names.
+- Security/Plugins/Hooks: validate install paths and reject traversal-like names (prevents path traversal outside the state dir). Thanks @logicx24.
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
@@ -1651,6 +2047,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
+- Security/Exec approvals: reject shell command substitution (`$()` and backticks) inside double quotes to prevent exec allowlist bypass when exec allowlist mode is explicitly enabled (the default configuration does not use this mode). Thanks @simecek.
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
- Telegram: serialize media-group processing to avoid missed albums under load.
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a5e9164a94d..355fb5c6890 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,24 +13,33 @@ Welcome to the lobster tank! 🦞
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
-- **Shadow** - Discord + Slack subsystem
+- **Shadow** - Discord subsystem, Discord admin
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
-- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
+- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
+- **Ayaan Zaidi** - Telegram subsystem, iOS app
+ - GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
+
+- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
+ - GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
+
+- **Mariano Belinky** - iOS app, Security
+ - GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
+
+- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
+ - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
+
- **Christoph Nakazawa** - JS Infra
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
-- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
- - GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
-
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
diff --git a/Dockerfile b/Dockerfile
index 716ab2099f7..2ead5c51fcd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,6 +23,19 @@ COPY scripts ./scripts
RUN pnpm install --frozen-lockfile
+# Optionally install Chromium and Xvfb for browser automation.
+# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
+# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
+# Must run after pnpm install so playwright-core is available in node_modules.
+ARG OPENCLAW_INSTALL_BROWSER=""
+RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
+ node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
+ fi
+
COPY . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common
new file mode 100644
index 00000000000..71f80070adf
--- /dev/null
+++ b/Dockerfile.sandbox-common
@@ -0,0 +1,45 @@
+ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
+FROM ${BASE_IMAGE}
+
+USER root
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
+ARG INSTALL_PNPM=1
+ARG INSTALL_BUN=1
+ARG BUN_INSTALL_DIR=/opt/bun
+ARG INSTALL_BREW=1
+ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
+ARG FINAL_USER=sandbox
+
+ENV BUN_INSTALL=${BUN_INSTALL_DIR}
+ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
+ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
+ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
+ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ${PACKAGES} \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
+
+RUN if [ "${INSTALL_BUN}" = "1" ]; then \
+ curl -fsSL https://bun.sh/install | bash; \
+ ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
+fi
+
+RUN if [ "${INSTALL_BREW}" = "1" ]; then \
+ if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
+ mkdir -p "${BREW_INSTALL_DIR}"; \
+ chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
+ su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
+ if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
+ if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
+ ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
+fi
+
+# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
+USER ${FINAL_USER}
+
diff --git a/README.md b/README.md
index b1a3b407a0e..6ec750692a1 100644
--- a/README.md
+++ b/README.md
@@ -112,9 +112,9 @@ Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
-- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
+- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `openclaw pairing approve ` (then the sender is added to a local allowlist store).
-- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
+- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
Run `openclaw doctor` to surface risky/misconfigured DM policies.
@@ -267,6 +267,7 @@ ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
+- `/mesh ` — auto-plan + run a multi-step workflow (`/mesh plan|run|status|retry` available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think ` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
@@ -303,6 +304,7 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
+- Install: [OpenClaw for Android](https://github.com/irtiq7/OpenClaw-Android).
## Agent workspace + skills
@@ -360,7 +362,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
### [Discord](https://docs.openclaw.ai/channels/discord)
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
-- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
+- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
```json5
{
@@ -546,4 +548,5 @@ Thanks to all clawtributors:
+
diff --git a/SECURITY.md b/SECURITY.md
index c3db26fa650..63440837047 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -39,6 +39,10 @@ Reports without reproduction steps, demonstrated impact, and remediation advice
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
The best way to help the project right now is by sending PRs.
+## Maintainers: GHSA Updates via CLI
+
+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.
+
## Out of Scope
- Public Internet Exposure
@@ -51,9 +55,22 @@ For threat model + hardening guidance (including `openclaw security audit --deep
- `https://docs.openclaw.ai/gateway/security`
+### 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.
+- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
+
### Web Interface Safety
-OpenClaw's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
+OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
+
+- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
+ - Config: `gateway.bind="loopback"` (default).
+ - CLI: `openclaw gateway run --bind loopback`.
+- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
+- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
+- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
## Runtime Requirements
diff --git a/appcast.xml b/appcast.xml
index dee0631ce05..3318fbaf86b 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -3,206 +3,311 @@
OpenClaw
-
-
2026.2.12
- Fri, 13 Feb 2026 03:17:54 +0100
+ 2026.2.14
+ Sun, 15 Feb 2026 04:24:34 +0100
https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 9500
- 2026.2.12
+ 202602140
+ 2026.2.14
15.0
- OpenClaw 2026.2.12
+ OpenClaw 2026.2.14
Changes
-CLI: add openclaw logs --local-time to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
-Telegram: render blockquotes as native tags instead of stripping them. (#14608)
-Config: avoid redacting maxTokens-like fields during config snapshot redaction, preventing round-trip validation failures in /config. (#14006) Thanks @constansino.
-
-Breaking
-
-Hooks: POST /hooks/agent now rejects payload sessionKey overrides by default. To keep fixed hook context, set hooks.defaultSessionKey (recommended with hooks.allowedSessionKeyPrefixes: ["hook:"]). If you need legacy behavior, explicitly set hooks.allowRequestSessionKey: true. Thanks @alpernae for reporting.
+Telegram: add poll sending via openclaw message poll (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
+Slack/Discord: add dmPolicy + allowFrom config aliases for DM access control; legacy dm.policy + dm.allowFrom keys remain supported and openclaw doctor --fix can migrate them.
+Discord: allow exec approval prompts to target channels or both DM+channel via channels.discord.execApprovals.target. (#16051) Thanks @leonnardo.
+Sandbox: add sandbox.browser.binds to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
+Discord: add debug logging for message routing decisions to improve --debug tracing. (#16202) Thanks @jayleekr.
Fixes
-Gateway/OpenResponses: harden URL-based input_file/input_image handling with explicit SSRF deny policy, hostname allowlists (files.urlAllowlist / images.urlAllowlist), per-request URL input caps (maxUrlParts), blocked-fetch audit logging, and regression coverage/docs updates.
-Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
-Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
-Security/Audit: add hook session-routing hardening checks (hooks.defaultSessionKey, hooks.allowRequestSessionKey, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
-Security/Sandbox: confine mirrored skill sync destinations to the sandbox skills/ root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
-Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip toolResult.details from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
-Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (429 + Retry-After). Thanks @akhmittra.
-Security/Browser: require auth for loopback browser control HTTP routes, auto-generate gateway.auth.token when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.
-Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
-Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
-Logging/CLI: use local timezone timestamps for console prefixing, and include ±HH:MM offsets when using openclaw logs --local-time to avoid ambiguity. (#14771) Thanks @0xRaini.
-Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
-Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
-Gateway: prevent undefined/missing token in auth config. (#13809) Thanks @asklee-klawd.
-Gateway: handle async EPIPE on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
-Gateway/Control UI: resolve missing dashboard assets when openclaw is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
-Cron: use requested agentId for isolated job auth resolution. (#13983) Thanks @0xRaini.
-Cron: prevent cron jobs from skipping execution when nextRunAtMs advances. (#14068) Thanks @WalterSumbon.
-Cron: pass agentId to runHeartbeatOnce for main-session jobs. (#14140) Thanks @ishikawa-pro.
-Cron: re-arm timers when onTimer fires while a job is still executing. (#14233) Thanks @tomron87.
-Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
-Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
-Cron: prevent one-shot at jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
-Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after requests-in-flight skips. (#14901) Thanks @joeykrug.
-Cron: honor stored session model overrides for isolated-agent runs while preserving hooks.gmail.model precedence for Gmail hook sessions. (#14983) Thanks @shtse8.
-Logging/Browser: fall back to os.tmpdir()/openclaw for default log, browser trace, and browser download temp paths when /tmp/openclaw is unavailable.
-WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.
-WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.
-WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.
-Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
-Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
-BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
-Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
-Slack: detect control commands when channel messages start with bot mention prefixes (for example, @Bot /new). (#14142) Thanks @beefiker.
-Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
-Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
-Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
-Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
-Signal: render mention placeholders as @uuid/@phone so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
-Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
-Onboarding/Providers: add Z.AI endpoint-specific auth choices (zai-coding-global, zai-coding-cn, zai-global, zai-cn) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
-Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include minimax-m2.5 in modern model filtering. (#14865) Thanks @adao-max.
-Ollama: use configured models.providers.ollama.baseUrl for model discovery and normalize /v1 endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
-Voice Call: pass Twilio stream auth token via instead of query string. (#14029) Thanks @mcwigglesmcgee.
-Feishu: pass Buffer directly to the Feishu SDK upload APIs instead of Readable.from(...) to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
-Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
-Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
-Feishu DocX: preserve top-level converted block order using firstLevelBlockIds when writing/appending documents. (#13994) Thanks @Cynosure159.
-Feishu plugin packaging: remove workspace:* openclaw dependency from extensions/feishu and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
-CLI/Wizard: exit with code 1 when configure, agents add, or interactive onboard wizards are canceled, so set -e automation stops correctly. (#14156) Thanks @0xRaini.
-Media: strip MEDIA: lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.
-Config/Cron: exclude maxTokens from config redaction and honor deleteAfterRun on skipped cron jobs. (#13342) Thanks @niceysam.
-Config: ignore meta field changes in config file watcher. (#13460) Thanks @brandonwise.
-Cron: use requested agentId for isolated job auth resolution. (#13983) Thanks @0xRaini.
-Cron: pass agentId to runHeartbeatOnce for main-session jobs. (#14140) Thanks @ishikawa-pro.
-Cron: prevent cron jobs from skipping execution when nextRunAtMs advances. (#14068) Thanks @WalterSumbon.
-Cron: re-arm timers when onTimer fires while a job is still executing. (#14233) Thanks @tomron87.
-Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
-Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
-Cron: prevent one-shot at jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
-Daemon: suppress EPIPE error when restarting LaunchAgent. (#14343) Thanks @0xRaini.
-Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.
-Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.
-Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.
-Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.
-Agents: keep followup-runner session totalTokens aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
-Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
-Hooks/Tools: dispatch before_tool_call and after_tool_call hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
-Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
-Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
-Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
+CLI/Plugins: ensure openclaw message send exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
+CLI/Plugins: run registered plugin gateway_stop hooks before openclaw message exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
+WhatsApp: honor per-account dmPolicy overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
+Telegram: when channels.telegram.commands.native is false, exclude plugin commands from setMyCommands menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
+LINE: return 200 OK for Developers Console "Verify" requests ({"events":[]}) without X-Line-Signature, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
+Cron: deliver text-only output directly when delivery.to is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
+Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
+Media: accept MEDIA:-prefixed paths (lenient whitespace) when loading outbound media to prevent ENOENT for tool-returned local media paths. (#13107) Thanks @mcaxtr.
+Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
+Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
+Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit workspaceDir. (#16722)
+BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
+CLI: fix lazy core command registration so top-level maintenance commands (doctor, dashboard, reset, uninstall) resolve correctly instead of exposing a non-functional maintenance placeholder command.
+CLI/Dashboard: when gateway.bind=lan, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
+TUI/Gateway: resolve local gateway target URL from gateway.bind mode (tailnet/lan) instead of hardcoded localhost so openclaw tui connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
+TUI: honor explicit --session in openclaw tui even when session.scope is global, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
+TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
+TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
+TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
+TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
+TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
+TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
+TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
+TUI/Hooks: pass explicit reset reason (new vs reset) through sessions.reset and emit internal command hooks for gateway-triggered resets so /new hook workflows fire in TUI/webchat.
+Cron: prevent cron list/cron status from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
+Cron: repair missing/corrupt nextRunAtMs for the updated job without globally recomputing unrelated due jobs during cron update. (#15750)
+Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale runningAtMs markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
+Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as guild=dm. Thanks @thewilloftheshadow.
+Discord: treat empty per-guild channels: {} config maps as no channel allowlist (not deny-all), so groupPolicy: "open" guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
+Models/CLI: guard models status string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
+Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
+Gateway/Sessions: abort active embedded runs and clear queued session work before sessions.reset, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
+Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
+Agents: add a safety timeout around embedded session.compact() to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
+Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including session_status model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
+Agents/Process/Bootstrap: preserve unbounded process log offset-only pagination (default tail applies only when both offset and limit are omitted) and enforce strict bootstrapTotalMaxChars budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
+Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing BOOTSTRAP.md once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
+Agents/Workspace: create BOOTSTRAP.md when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
+Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
+Agents: treat empty-stream provider failures (request ended without sending any chunks) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
+Agents: treat read tool file_path arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
+Ollama/Agents: avoid forcing tag enforcement for Ollama models, which could suppress all output as (no output). (#16191) Thanks @Glucksberg.
+Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
+Skills: watch SKILL.md only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
+Memory/QMD: make memory status read-only by skipping QMD boot update/embed side effects for status-only manager checks.
+Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
+Memory/Builtin: keep memory status dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
+Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological qmd command output.
+Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
+Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
+Memory/QMD: pass result limits to search/vsearch commands so QMD can cap results earlier.
+Memory/QMD: avoid reading full markdown files when a from/lines window is requested in QMD reads.
+Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
+Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy stdout.
+Memory/QMD: treat prefixed no results found marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
+Memory/QMD: avoid multi-collection query ranking corruption by running one qmd query -c per managed collection and merging by best score (also used for search/vsearch fallback-to-query). (#16740) Thanks @volarian-vai.
+Memory/QMD: detect null-byte ENOTDIR update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
+Memory/QMD/Security: add rawKeyPrefix support for QMD scope rules and preserve legacy keyPrefix: "agent:..." matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
+Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
+Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
+Security/Memory-LanceDB: require explicit autoCapture: true opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
+Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
+Gateway/Memory: clean up agentRunSeq tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
+Auto-reply/Memory: bound ABORT_MEMORY growth by evicting oldest entries and deleting reset (false) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
+Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
+Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
+Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
+Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
+Media/Security: allow local media reads from OpenClaw state workspace/ and sandboxes/ roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
+Media/Security: harden local media allowlist bypasses by requiring an explicit readFile override when callers mark paths as validated, and reject filesystem-root localRoots entries. (#16739)
+Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
+Security/BlueBubbles: require explicit mediaLocalRoots allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
+Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
+Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
+Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
+Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
+Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
+Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
+Security/Hooks: restrict hook transform modules to ~/.openclaw/hooks/transforms (prevents path traversal/escape module loads via config). Config note: hooks.transformsDir must now be within that directory. Thanks @akhmittra.
+Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
+Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
+Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
+Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
+Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
+Security/Slack: compute command authorization for DM slash commands even when dmPolicy=open, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
+Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
+Security/Google Chat: deprecate users/ allowlists (treat users/... as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
+Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
+Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject @username principals), auto-resolve @username to IDs in openclaw doctor --fix (when possible), and warn in openclaw security audit when legacy configs contain usernames. Thanks @vincentkoc.
+Telegram/Security: reject Telegram webhook startup when webhookSecret is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
+Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
+Telegram: set webhook callback timeout handling to onTimeout: "return" (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
+Signal: preserve case-sensitive group: target IDs during normalization so mixed-case group IDs no longer fail with Group not found. (#16748) Thanks @repfigit.
+Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
+Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
+Security/Agents: enforce workspace-root path bounds for apply_patch in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
+Security/Agents: enforce symlink-escape checks for apply_patch delete hunks under workspaceOnly, while still allowing deleting the symlink itself. Thanks @p80n-sec.
+Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
+macOS: hard-limit unkeyed openclaw://agent deep links and ignore deliver / to / channel unless a valid unattended key is provided. Thanks @Cillian-Collins.
+Scripts/Security: validate GitHub logins and avoid shell invocation in scripts/update-clawtributors.ts to prevent command injection via malicious commit records. Thanks @scanleale.
+Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
+Security/Gateway: harden tool-supplied gatewayUrl overrides by restricting them to loopback or the configured gateway.remote.url. Thanks @p80n-sec.
+Security/Gateway: block system.execApprovals.* via node.invoke (use exec.approvals.node.* instead). Thanks @christos-eth.
+Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
+Security/Gateway: stop returning raw resolved config values in skills.status requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
+Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
+Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
+Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
+Security/Node Host: enforce system.run rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
+Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
+Security/Exec: harden PATH handling by disabling project-local node_modules/.bin bootstrapping by default, disallowing node-host PATH overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
+Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: channels.tlon.allowPrivateNetwork). Thanks @p80n-sec.
+Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without telnyx.publicKey are now rejected unless skipSignatureVerification is enabled. Thanks @p80n-sec.
+Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
+Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
View full changelog
]]>
-
+
-
-
2026.2.9
- Mon, 09 Feb 2026 13:23:25 -0600
+ 2026.2.15
+ Mon, 16 Feb 2026 05:04:34 +0100
https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 9194
- 2026.2.9
+ 202602150
+ 2026.2.15
15.0
- OpenClaw 2026.2.9
-Added
-
-iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
-Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
-Plugins: device pairing + phone control plugins (Telegram /pair, iOS/Android node controls). (#11755) Thanks @mbelinky.
-Tools: add Grok (xAI) as a web_search provider. (#12419) Thanks @tmchow.
-Gateway: add agent management RPC methods for the web UI (agents.create, agents.update, agents.delete). (#11045) Thanks @advaitpaliwal.
-Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.
-Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
-Paths: add OPENCLAW_HOME for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
-
-Fixes
-
-Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
-Telegram: recover proactive sends when stale topic thread IDs are used by retrying without message_thread_id. (#11620)
-Telegram: render markdown spoilers with HTML tags. (#11543) Thanks @ezhikkk.
-Telegram: truncate command registration to 100 entries to avoid BOT_COMMANDS_TOO_MUCH failures on startup. (#12356) Thanks @arosstale.
-Telegram: match DM allowFrom against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
-Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
-Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
-Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
-Tools/web_search: include provider-specific settings in the web search cache key, and pass inlineCitations for Grok. (#12419) Thanks @tmchow.
-Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
-Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.
-Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.
-Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session parentId chain so agents can remember again. (#12283) Thanks @Takhoffman.
-Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.
-Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
-Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.
-Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.
-Cron tool: recover flat params when LLM omits the job wrapper for add requests. (#12124) Thanks @tyler6204.
-Gateway/CLI: when gateway.bind=lan, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
-Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.
-Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.
-Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
-Config: clamp maxTokens to contextWindow to prevent invalid model configs. (#5516) Thanks @lailoo.
-Thinking: allow xhigh for github-copilot/gpt-5.2-codex and github-copilot/gpt-5.2. (#11646) Thanks @LatencyTDH.
-Discord: support forum/media thread-create starter messages, wire message thread create --message, and harden routing. (#10062) Thanks @jarvis89757.
-Paths: structurally resolve OPENCLAW_HOME-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
-Memory: set Voyage embeddings input_type for improved retrieval. (#10818) Thanks @mcinteerj.
-Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.
-Media understanding: recognize .caf audio attachments for transcription. (#10982) Thanks @succ985.
-State dir: honor OPENCLAW_STATE_DIR for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
-
-View full changelog
-]]>
-
-
- -
-
2026.2.3
- Wed, 04 Feb 2026 17:47:10 -0800
- https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 8900
- 2026.2.3
- 15.0
- OpenClaw 2026.2.3
+ OpenClaw 2026.2.15
Changes
-Telegram: remove last @ts-nocheck from bot-handlers.ts, use Grammy types directly, deduplicate StickerMetadata. Zero @ts-nocheck remaining in src/telegram/. (#9206)
-Telegram: remove @ts-nocheck from bot-message.ts, type deps via Omit, widen allMedia to TelegramMediaRef[]. (#9180)
-Telegram: remove @ts-nocheck from bot.ts, fix duplicate bot.catch error handler (Grammy overrides), remove dead reaction message_thread_id routing, harden sticker cache guard. (#9077)
-Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
-Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
-Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
-Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
-Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
-Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
-Cron: default isolated jobs to announce delivery; accept ISO 8601 schedule.at in tool inputs.
-Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and atMs inputs.
-Cron: delete one-shot jobs after success by default; add --keep-after-run for CLI.
-Cron: suppress messaging tools during announce delivery so summaries post consistently.
-Cron: avoid duplicate deliveries when isolated runs send messages directly.
+Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
+Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
+Plugins: expose llm_input and llm_output hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
+Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set agents.defaults.subagents.maxSpawnDepth: 2 to allow sub-agents to spawn their own children. Includes maxChildrenPerAgent limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
+Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
+Cron/Gateway: add finished-run webhook delivery toggle (notify) and dedicated webhook auth token support (cron.webhookToken) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
+Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
Fixes
-Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
-TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
-Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
-Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
-Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.
-Web UI: resolve header logo path when gateway.controlUi.basePath is set. (#7178) Thanks @Yeom-JinHo.
-Web UI: apply button styling to the new-messages indicator.
-Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
-Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
-Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
-Security: gate whatsapp_login tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
-Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
-Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.
-Cron: accept epoch timestamps and 0ms durations in CLI --at parsing.
-Cron: reload store data when the store file is recreated or mtime changes.
-Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
-Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
-macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
+Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
+Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
+Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
+Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
+Gateway/Security: redact sensitive session/path details from status responses for non-admin clients; full details remain available to operator.admin. (#8590) Thanks @fr33d3m0n.
+Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (allowInsecureAuth / dangerouslyDisableDeviceAuth) when device identity is unavailable, preventing false missing scope failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
+LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
+Skills/Security: restrict download installer targetDir to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
+Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
+Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
+Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving passwordFile path exemptions, preventing accidental redaction of non-secret config values like maxTokens and IRC password-file paths. (#16042) Thanks @akramcodez.
+Dev tooling: harden git pre-commit hook against option injection from malicious filenames (for example --force), preventing accidental staging of ignored files. Thanks @mrthankyou.
+Gateway/Agent: reject malformed agent:-prefixed session keys (for example, agent:main) in agent and agent.identity.get instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
+Gateway/Chat: harden chat.send inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
+Gateway/Send: return an actionable error when send targets internal-only webchat, guiding callers to use chat.send or a deliverable channel. (#15703) Thanks @rodrigouroz.
+Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing script-src 'self'. Thanks @Adam55A-code.
+Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
+Agents/Sandbox: clarify system prompt path guidance so sandbox bash/exec uses container paths (for example /workspace) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
+Agents/Context: apply configured model contextWindow overrides after provider discovery so lookupContextTokens() honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
+Agents/Context: derive lookupContextTokens() from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
+Agents/OpenAI: force store=true for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
+Memory/FTS: make buildFtsQuery Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
+Auto-reply/Compaction: resolve memory/YYYY-MM-DD.md placeholders with timezone-aware runtime dates and append a Current time: line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
+Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
+Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
+Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
+Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
+Subagents/Models: preserve agents.defaults.model.fallbacks when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
+Telegram: omit message_thread_id for DM sends/draft previews and keep forum-topic handling (id=1 general omitted, non-general kept), preventing DM failures with 400 Bad Request: message thread not found. (#10942) Thanks @garnetlyx.
+Telegram: replace inbound placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
+Telegram: retry inbound media getFile calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
+Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
+Discord: preserve channel session continuity when runtime payloads omit message.channelId by falling back to event/raw channel_id values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as sessionKey=unknown. (#17622) Thanks @shakkernerd.
+Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with _2 suffixes. (#17365) Thanks @seewhyme.
+Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
+Web UI/Agents: hide BOOTSTRAP.md in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
+Auto-reply/WhatsApp/TUI/Web: when a final assistant message is NO_REPLY and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show NO_REPLY placeholders. (#7010) Thanks @Morrowind-Xie.
+Cron: infer payload.kind="agentTurn" for model-only cron.update payload patches, so partial agent-turn updates do not fail validation when kind is omitted. (#15664) Thanks @rodrigouroz.
+TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
+TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
+TUI: suppress false (no output) placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
+TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
+CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
View full changelog
]]>
-
+
+
+ -
+
2026.2.13
+ Sat, 14 Feb 2026 04:30:23 +0100
+ https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
+ 9846
+ 2026.2.13
+ 15.0
+ OpenClaw 2026.2.13
+Changes
+
+Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
+Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
+Slack/Plugins: add thread-ownership outbound gating via message_sending hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
+Agents: add synthetic catalog support for hf:zai-org/GLM-5. (#15867) Thanks @battman21.
+Skills: remove duplicate local-places Google Places skill/proxy and keep goplaces as the single supported Google Places path.
+Agents: add pre-prompt context diagnostics (messages, systemPromptChars, promptChars, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
+
+Fixes
+
+Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
+Auto-reply/Threading: auto-inject implicit reply threading so replyToMode works without requiring model-emitted [[reply_to_current]], while preserving replyToMode: "off" behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under replyToMode: "first". (#14976) Thanks @Diaspar4u.
+Outbound/Threading: pass replyTo and threadId from message send tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
+Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
+Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
+Web UI: add img to DOMPurify allowed tags and src/alt to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
+Telegram/Matrix: treat MP3 and M4A (including audio/mp4) as voice-compatible for asVoice routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.
+WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending "file". (#15594) Thanks @TsekaLuk.
+Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
+Telegram: scope skill commands to the resolved agent for default accounts so setMyCommands no longer triggers BOT_COMMANDS_TOO_MUCH when multiple agents are configured. (#15599)
+Discord: avoid misrouting numeric guild allowlist entries to /channels/ by prefixing guild-only inputs with guild: during resolution. (#12326) Thanks @headswim.
+MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (29:..., 8:orgid:...) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
+Media: classify text/* MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
+Inbound/Web UI: preserve literal \n sequences when normalizing inbound text so Windows paths like C:\\Work\\nxxx\\README.md are not corrupted. (#11547) Thanks @mcaxtr.
+TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
+Providers/MiniMax: switch implicit MiniMax API-key provider from openai-completions to anthropic-messages with the correct Anthropic-compatible base URL, fixing invalid role: developer (2013) errors on MiniMax M2.5. (#15275) Thanks @lailoo.
+Ollama/Agents: use resolved model/provider base URLs for native /api/chat streaming (including aliased providers), normalize /v1 endpoints, and forward abort + maxTokens stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
+OpenAI Codex/Spark: implement end-to-end gpt-5.3-codex-spark support across fallback/thinking/model resolution and models list forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
+Agents/Codex: allow gpt-5.3-codex-spark in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.
+Models/Codex: resolve configured openai-codex/gpt-5.3-codex-spark through forward-compat fallback during models list, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.
+OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into pi auth.json so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
+Auth/OpenAI Codex: share OAuth login handling across onboarding and models auth login --provider openai-codex, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
+Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
+Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (tokenProvider=huggingface with authChoice=apiKey) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
+Onboarding/CLI: restore terminal state without resuming paused stdin, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
+Signal/Install: auto-install signal-cli via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary Exec format error failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
+macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
+Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
+Discord/Agents: apply channel/group historyLimit during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
+Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
+Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
+Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
+Heartbeat: allow explicit wake (wake) and hook wake (hook:*) reasons to run even when HEARTBEAT.md is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
+Auto-reply/Heartbeat: strip sentence-ending HEARTBEAT_OK tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
+Agents/Heartbeat: stop auto-creating HEARTBEAT.md during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
+Sessions/Agents: pass agentId when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with Session file path must be within sessions directory. (#15141) Thanks @Goldenmonstew.
+Sessions/Agents: pass agentId through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
+Sessions: archive previous transcript files on /new and /reset session resets (including gateway sessions.reset) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
+Status/Sessions: stop clamping derived totalTokens to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
+CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid source <(openclaw completion ...) corruption. (#15481) Thanks @arosstale.
+CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
+Security/Gateway + ACP: block high-risk tools (sessions_spawn, sessions_send, gateway, whatsapp_login) from HTTP /tools/invoke by default with gateway.tools.{allow,deny} overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting allow_always/reject_always. (#15390) Thanks @aether-ai-agent.
+Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
+Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
+Security/Browser: constrain POST /trace/stop, POST /wait/download, and POST /download output paths to OpenClaw temp roots and reject traversal/escape paths.
+Security/Canvas: serve A2UI assets via the shared safe-open path (openFileWithinRoot) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
+Security/WhatsApp: enforce 0o600 on creds.json and creds.json.bak on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
+Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
+Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective gateway.nodes.denyCommands entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
+Security/Audit: distinguish external webhooks (hooks.enabled) from internal hooks (hooks.internal.enabled) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
+Security/Onboarding: clarify multi-user DM isolation remediation with explicit openclaw config set session.dmScope ... commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
+Agents/Nodes: harden node exec approval decision handling in the nodes tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.
+Android/Nodes: harden app.update by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
+Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
+Exec/Allowlist: allow multiline heredoc bodies (<<, <<-) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
+Config: preserve ${VAR} env references when writing config files so openclaw config set/apply/patch does not persist secrets to disk. Thanks @thewilloftheshadow.
+Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving ${VAR} refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
+Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
+Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
+Config: accept $schema key in config file so JSON Schema editor tooling works without validation errors. (#14998)
+Gateway/Tools Invoke: sanitize /tools/invoke execution failures while preserving 400 for tool input errors and returning 500 for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
+Gateway/Hooks: preserve 408 for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
+Plugins/Hooks: fire before_tool_call hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
+Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
+Agents/Image tool: cap image-analysis completion maxTokens by model capability (min(4096, model.maxTokens)) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
+Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent tools.exec overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
+Gateway/Agents: stop injecting a phantom main agent into gateway agent listings when agents.list explicitly excludes it. (#11450) Thanks @arosstale.
+Process/Exec: avoid shell execution for .exe commands on Windows so env overrides work reliably in runCommandWithTimeout. Thanks @thewilloftheshadow.
+Daemon/Windows: preserve literal backslashes in gateway.cmd command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
+Sandbox: pass configured sandbox.docker.env variables to sandbox containers at docker create time. (#15138) Thanks @stevebot-alive.
+Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.
+Cron: add regression coverage for announce-mode isolated jobs so runs that already report delivered: true do not enqueue duplicate main-session relays, including delivery configs where mode is omitted and defaults to announce. (#15737) Thanks @brandonwise.
+Cron: honor deleteAfterRun in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
+Web tools/web_fetch: prefer text/markdown responses for Cloudflare Markdown for Agents, add cf-markdown extraction for markdown bodies, and redact fetched URLs in x-markdown-tokens debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
+Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
+Memory: switch default local embedding model to the QAT embeddinggemma-300m-qat-Q8_0 variant for better quality at the same footprint. (#15429) Thanks @azade-c.
+Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
+
+View full changelog
+]]>
+
\ No newline at end of file
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 4bd44b8efd6..148b2e58a75 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
- versionCode = 202602130
- versionName = "2026.2.13"
+ versionCode = 202602160
+ versionName = "2026.2.16"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -63,7 +63,11 @@ android {
}
lint {
- disable += setOf("IconLauncherShape")
+ disable += setOf(
+ "GradleDependency",
+ "IconLauncherShape",
+ "NewerVersionAvailable",
+ )
warningsAsErrors = true
}
@@ -121,6 +125,7 @@ dependencies {
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")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
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 1886e0f4be8..d9123d10293 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
@@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val statusText: StateFlow = runtime.statusText
val serverName: StateFlow = runtime.serverName
val remoteAddress: StateFlow = runtime.remoteAddress
+ val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust
val isForeground: StateFlow = runtime.isForeground
val seamColorArgb: StateFlow = runtime.seamColorArgb
val mainSessionKey: StateFlow = runtime.mainSessionKey
@@ -145,6 +146,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect()
}
+ fun acceptGatewayTrustPrompt() {
+ runtime.acceptGatewayTrustPrompt()
+ }
+
+ fun declineGatewayTrustPrompt() {
+ runtime.declineGatewayTrustPrompt()
+ }
+
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
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 51daeff5ab4..aec192c25bb 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
@@ -15,6 +15,7 @@ import ai.openclaw.android.gateway.DeviceIdentityStore
import ai.openclaw.android.gateway.GatewayDiscovery
import ai.openclaw.android.gateway.GatewayEndpoint
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
@@ -166,12 +167,20 @@ class NodeRuntime(context: Context) {
private lateinit var gatewayEventHandler: GatewayEventHandler
+ data class GatewayTrustPrompt(
+ val endpoint: GatewayEndpoint,
+ val fingerprintSha256: String,
+ )
+
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow = _isConnected.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow = _statusText.asStateFlow()
+ private val _pendingGatewayTrust = MutableStateFlow(null)
+ val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow()
+
private val _mainSessionKey = MutableStateFlow("main")
val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow()
@@ -405,8 +414,11 @@ class NodeRuntime(context: Context) {
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
- // Persist the last discovered gateway (best-effort UX parity with iOS).
- prefs.setLastDiscoveredStableId(list.last().stableId)
+ // Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
+ // UX parity with iOS: only set once when unset.
+ if (lastDiscoveredStableId.value.trim().isEmpty()) {
+ prefs.setLastDiscoveredStableId(list.first().stableId)
+ }
}
if (didAutoConnect) return@collect
@@ -416,6 +428,12 @@ class NodeRuntime(context: Context) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
+ // Security: autoconnect only to previously trusted gateways (stored TLS pin).
+ if (!manualTls.value) return@collect
+ val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
+ val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
+ if (storedFingerprint.isEmpty()) return@collect
+
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
@@ -425,6 +443,11 @@ class NodeRuntime(context: Context) {
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
+
+ // Security: autoconnect only to previously trusted gateways (stored TLS pin).
+ val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
+ if (storedFingerprint.isEmpty()) return@collect
+
didAutoConnect = true
connect(target)
}
@@ -520,17 +543,42 @@ class NodeRuntime(context: Context) {
}
fun connect(endpoint: GatewayEndpoint) {
+ val tls = connectionManager.resolveTlsParams(endpoint)
+ if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
+ // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
+ _statusText.value = "Verify gateway TLS fingerprint…"
+ scope.launch {
+ val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
+ _statusText.value = "Failed: can't read TLS fingerprint"
+ return@launch
+ }
+ _pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
+ }
+ return
+ }
+
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
- val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
}
+ fun acceptGatewayTrustPrompt() {
+ val prompt = _pendingGatewayTrust.value ?: return
+ _pendingGatewayTrust.value = null
+ prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
+ connect(prompt.endpoint)
+ }
+
+ fun declineGatewayTrustPrompt() {
+ _pendingGatewayTrust.value = null
+ _statusText.value = "Offline"
+ }
+
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
@@ -550,6 +598,7 @@ class NodeRuntime(context: Context) {
fun disconnect() {
connectedEndpoint = null
+ _pendingGatewayTrust.value = null
operatorSession.disconnect()
nodeSession.disconnect()
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt
index dc17aa73292..0726c94fc97 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt
@@ -1,13 +1,21 @@
package ai.openclaw.android.gateway
import android.annotation.SuppressLint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.InetSocketAddress
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
+import java.util.Locale
+import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.SNIHostName
+import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@@ -59,13 +67,74 @@ fun buildGatewayTlsConfig(
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
+ val verifier =
+ if (expected != null || params.allowTOFU) {
+ // When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
+ HostnameVerifier { _, _ -> true }
+ } else {
+ HttpsURLConnection.getDefaultHostnameVerifier()
+ }
return GatewayTlsConfig(
sslSocketFactory = context.socketFactory,
trustManager = trustManager,
- hostnameVerifier = HostnameVerifier { _, _ -> true },
+ hostnameVerifier = verifier,
)
}
+suspend fun probeGatewayTlsFingerprint(
+ host: String,
+ port: Int,
+ timeoutMs: Int = 3_000,
+): String? {
+ val trimmedHost = host.trim()
+ if (trimmedHost.isEmpty()) return null
+ if (port !in 1..65535) return null
+
+ return withContext(Dispatchers.IO) {
+ val trustAll =
+ @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
+ object : X509TrustManager {
+ @SuppressLint("TrustAllX509TrustManager")
+ override fun checkClientTrusted(chain: Array, authType: String) {}
+ @SuppressLint("TrustAllX509TrustManager")
+ override fun checkServerTrusted(chain: Array, authType: String) {}
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ }
+
+ val context = SSLContext.getInstance("TLS")
+ context.init(null, arrayOf(trustAll), SecureRandom())
+
+ val socket = (context.socketFactory.createSocket() as SSLSocket)
+ try {
+ socket.soTimeout = timeoutMs
+ socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
+
+ // Best-effort SNI for hostnames (avoid crashing on IP literals).
+ try {
+ if (trimmedHost.any { it.isLetter() }) {
+ val params = SSLParameters()
+ params.serverNames = listOf(SNIHostName(trimmedHost))
+ socket.sslParameters = params
+ }
+ } catch (_: Throwable) {
+ // ignore
+ }
+
+ socket.startHandshake()
+ val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
+ sha256Hex(cert.encoded)
+ } catch (_: Throwable) {
+ null
+ } finally {
+ try {
+ socket.close()
+ } catch (_: Throwable) {
+ // ignore
+ }
+ }
+ }
+}
+
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)
@@ -78,7 +147,7 @@ private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
- out.append(String.format("%02x", byte))
+ out.append(String.format(Locale.US, "%02x", byte))
}
return out.toString()
}
@@ -86,5 +155,5 @@ private fun sha256Hex(data: ByteArray): String {
private fun normalizeFingerprint(raw: String): String {
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
- return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
+ return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt
index 7472544d317..e54c846c0fb 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt
@@ -187,11 +187,11 @@ class AppUpdateHandler(
lastNotifUpdate = now
if (contentLength > 0) {
val pct = ((totalBytes * 100) / contentLength).toInt()
- val mb = String.format("%.1f", totalBytes / 1048576.0)
- val totalMb = String.format("%.1f", contentLength / 1048576.0)
+ val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
+ val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
} else {
- val mb = String.format("%.1f", totalBytes / 1048576.0)
+ val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
}
}
@@ -239,13 +239,15 @@ class AppUpdateHandler(
// Use PackageInstaller session API — works from background on API 34+
// The system handles showing the install confirmation dialog
notifManager.cancel(notifId)
- notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_sys_download_done)
- .setContentTitle("Installing Update...")
-
+ notifManager.notify(
+ notifId,
+ android.app.Notification.Builder(appContext, channelId)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentTitle("Installing Update...")
.setContentIntent(launchPi)
- .setContentText("${String.format("%.1f", totalBytes / 1048576.0)} MB downloaded")
- .build())
+ .setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
+ .build(),
+ )
val installer = appContext.packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
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 3b413d2d68b..d15d928e0a4 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
@@ -26,6 +26,59 @@ class ConnectionManager(
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
+ companion object {
+ internal fun resolveTlsParamsForEndpoint(
+ endpoint: GatewayEndpoint,
+ storedFingerprint: String?,
+ manualTlsEnabled: Boolean,
+ ): GatewayTlsParams? {
+ val stableId = endpoint.stableId
+ val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
+ val isManual = stableId.startsWith("manual|")
+
+ if (isManual) {
+ if (!manualTlsEnabled) return null
+ if (!stored.isNullOrBlank()) {
+ return GatewayTlsParams(
+ required = true,
+ expectedFingerprint = stored,
+ allowTOFU = false,
+ stableId = stableId,
+ )
+ }
+ return GatewayTlsParams(
+ required = true,
+ expectedFingerprint = null,
+ allowTOFU = false,
+ stableId = stableId,
+ )
+ }
+
+ // Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
+ if (!stored.isNullOrBlank()) {
+ return GatewayTlsParams(
+ required = true,
+ expectedFingerprint = stored,
+ allowTOFU = false,
+ stableId = stableId,
+ )
+ }
+
+ val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
+ if (hinted) {
+ // TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
+ return GatewayTlsParams(
+ required = true,
+ expectedFingerprint = null,
+ allowTOFU = false,
+ stableId = stableId,
+ )
+ }
+
+ return null
+ }
+ }
+
fun buildInvokeCommands(): List =
buildList {
add(OpenClawCanvasCommand.Present.rawValue)
@@ -130,37 +183,6 @@ class ConnectionManager(
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
- val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
- val manual = endpoint.stableId.startsWith("manual|")
-
- if (manual) {
- if (!manualTls()) return null
- return GatewayTlsParams(
- required = true,
- expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
- allowTOFU = stored == null,
- stableId = endpoint.stableId,
- )
- }
-
- if (hinted) {
- return GatewayTlsParams(
- required = true,
- expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
- allowTOFU = stored == null,
- stableId = endpoint.stableId,
- )
- }
-
- if (!stored.isNullOrBlank()) {
- return GatewayTlsParams(
- required = true,
- expectedFingerprint = stored,
- allowTOFU = false,
- stableId = endpoint.stableId,
- )
- }
-
- return null
+ return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
}
}
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 eb3d77860ab..bb04c30108c 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
@@ -34,6 +34,7 @@ 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -42,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField
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.collectAsState
@@ -89,6 +91,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
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("") }
@@ -112,6 +115,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
+ 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")
+ }
+ },
+ )
+ }
+
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt
new file mode 100644
index 00000000000..534b90a2121
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt
@@ -0,0 +1,76 @@
+package ai.openclaw.android.node
+
+import ai.openclaw.android.gateway.GatewayEndpoint
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ConnectionManagerTest {
+ @Test
+ fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
+ val endpoint =
+ GatewayEndpoint(
+ stableId = "_openclaw-gw._tcp.|local.|Test",
+ name = "Test",
+ host = "10.0.0.2",
+ port = 18789,
+ tlsEnabled = true,
+ tlsFingerprintSha256 = "attacker",
+ )
+
+ val params =
+ ConnectionManager.resolveTlsParamsForEndpoint(
+ endpoint,
+ storedFingerprint = "legit",
+ manualTlsEnabled = false,
+ )
+
+ assertEquals("legit", params?.expectedFingerprint)
+ assertEquals(false, params?.allowTOFU)
+ }
+
+ @Test
+ fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() {
+ val endpoint =
+ GatewayEndpoint(
+ stableId = "_openclaw-gw._tcp.|local.|Test",
+ name = "Test",
+ host = "10.0.0.2",
+ port = 18789,
+ tlsEnabled = true,
+ tlsFingerprintSha256 = "attacker",
+ )
+
+ val params =
+ ConnectionManager.resolveTlsParamsForEndpoint(
+ endpoint,
+ storedFingerprint = null,
+ manualTlsEnabled = false,
+ )
+
+ assertNull(params?.expectedFingerprint)
+ assertEquals(false, params?.allowTOFU)
+ }
+
+ @Test
+ fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
+ val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
+
+ val off =
+ ConnectionManager.resolveTlsParamsForEndpoint(
+ endpoint,
+ storedFingerprint = null,
+ manualTlsEnabled = false,
+ )
+ assertNull(off)
+
+ val on =
+ ConnectionManager.resolveTlsParamsForEndpoint(
+ endpoint,
+ storedFingerprint = null,
+ manualTlsEnabled = true,
+ )
+ assertNull(on?.expectedFingerprint)
+ assertEquals(false, on?.allowTOFU)
+ }
+}
diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift
index 9ac83dd3928..94b2d9ea3f5 100644
--- a/apps/ios/Sources/Calendar/CalendarService.swift
+++ b/apps/ios/Sources/Calendar/CalendarService.swift
@@ -6,7 +6,7 @@ final class CalendarService: CalendarServicing {
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
- let authorized = await Self.ensureAuthorization(store: store, status: status)
+ let authorized = EventKitAuthorization.allowsRead(status: status)
guard authorized else {
throw NSError(domain: "Calendar", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
@@ -39,7 +39,7 @@ final class CalendarService: CalendarServicing {
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
- let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
+ let authorized = EventKitAuthorization.allowsWrite(status: status)
guard authorized else {
throw NSError(domain: "Calendar", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
@@ -95,38 +95,6 @@ final class CalendarService: CalendarServicing {
return OpenClawCalendarAddPayload(event: payload)
}
- private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
- switch status {
- case .authorized:
- return true
- case .notDetermined:
- // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
- return false
- case .restricted, .denied:
- return false
- case .fullAccess:
- return true
- case .writeOnly:
- return false
- @unknown default:
- return false
- }
- }
-
- private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
- switch status {
- case .authorized, .fullAccess, .writeOnly:
- return true
- case .notDetermined:
- // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
- return false
- case .restricted, .denied:
- return false
- @unknown default:
- return false
- }
- }
-
private static func resolveCalendar(
store: EKEventStore,
calendarId: String?,
diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift
index e76dbeeabb9..1e9c10bc44c 100644
--- a/apps/ios/Sources/Camera/CameraController.swift
+++ b/apps/ios/Sources/Camera/CameraController.swift
@@ -93,14 +93,10 @@ actor CameraController {
}
withExtendedLifetime(delegate) {}
- let maxPayloadBytes = 5 * 1024 * 1024
- // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
- let maxEncodedBytes = (maxPayloadBytes / 4) * 3
- let res = try JPEGTranscoder.transcodeToJPEG(
- imageData: rawData,
+ let res = try PhotoCapture.transcodeJPEGForGateway(
+ rawData: rawData,
maxWidthPx: maxWidth,
- quality: quality,
- maxBytes: maxEncodedBytes)
+ quality: quality)
return (
format: format.rawValue,
@@ -335,8 +331,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
- error: Error?)
- {
+ error: Error?
+ ) {
guard !self.didResume else { return }
self.didResume = true
@@ -364,8 +360,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
- error: Error?)
- {
+ error: Error?
+ ) {
guard let error else { return }
guard !self.didResume else { return }
self.didResume = true
diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
index 3c828551ada..9571839059d 100644
--- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
+++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
@@ -2,8 +2,10 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Foundation
+import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
+ private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
private let gateway: GatewayNodeSession
init(gateway: GatewayNodeSession) {
@@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
- struct Subscribe: Codable { var sessionKey: String }
- let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
- let json = String(data: data, encoding: .utf8)
- await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
+ // Operator clients receive chat events without node-style subscriptions.
+ // (chat.subscribe is a node event, not an operator RPC method.)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
+ Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
struct Params: Codable {
var sessionKey: String
var message: String
@@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
- let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
- return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
+ do {
+ let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
+ let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
+ Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
+ return decoded
+ } catch {
+ Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
+ throw error
+ }
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
diff --git a/apps/ios/Sources/EventKit/EventKitAuthorization.swift b/apps/ios/Sources/EventKit/EventKitAuthorization.swift
new file mode 100644
index 00000000000..c27e9a3efde
--- /dev/null
+++ b/apps/ios/Sources/EventKit/EventKitAuthorization.swift
@@ -0,0 +1,34 @@
+import EventKit
+
+enum EventKitAuthorization {
+ static func allowsRead(status: EKAuthorizationStatus) -> Bool {
+ switch status {
+ case .authorized, .fullAccess:
+ return true
+ case .writeOnly:
+ return false
+ case .notDetermined:
+ // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
+ return false
+ case .restricted, .denied:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+
+ static func allowsWrite(status: EKAuthorizationStatus) -> Bool {
+ switch status {
+ case .authorized, .fullAccess, .writeOnly:
+ return true
+ case .notDetermined:
+ // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
+ return false
+ case .restricted, .denied:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+}
+
diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
index 34af7f1dc06..132b32d364c 100644
--- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift
+++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
@@ -2,6 +2,7 @@ import AVFoundation
import Contacts
import CoreLocation
import CoreMotion
+import CryptoKit
import EventKit
import Foundation
import OpenClawKit
@@ -9,6 +10,7 @@ import Network
import Observation
import Photos
import ReplayKit
+import Security
import Speech
import SwiftUI
import UIKit
@@ -16,13 +18,27 @@ import UIKit
@MainActor
@Observable
final class GatewayConnectionController {
+ struct TrustPrompt: Identifiable, Equatable {
+ let stableID: String
+ let gatewayName: String
+ let host: String
+ let port: Int
+ let fingerprintSha256: String
+ let isManual: Bool
+
+ var id: String { self.stableID }
+ }
+
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
+ private(set) var pendingTrustPrompt: TrustPrompt?
private let discovery = GatewayDiscoveryModel()
private weak var appModel: NodeAppModel?
private var didAutoConnect = false
+ private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
+ private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)?
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel
@@ -56,31 +72,89 @@ final class GatewayConnectionController {
}
}
- func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
+ func allowAutoConnectAgain() {
+ self.didAutoConnect = false
+ self.maybeAutoConnect()
+ }
+
+ func restartDiscovery() {
+ self.discovery.stop()
+ self.didAutoConnect = false
+ self.discovery.start()
+ self.updateFromDiscovery()
+ }
+
+
+ /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
+ func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
+ await self.connectDiscoveredGateway(gateway)
+ }
+
+ private func connectDiscoveredGateway(
+ _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
+ {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if instanceId.isEmpty {
+ return "Missing instanceId (node.instanceId). Try restarting the app."
+ }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
- guard let host = self.resolveGatewayHost(gateway) else { return }
- let port = gateway.gatewayPort ?? 18789
- let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
+
+ // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
+ guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else {
+ return "Failed to resolve the discovered gateway endpoint."
+ }
+
+ let stableID = gateway.stableID
+ // Discovery is a LAN operation; refuse unauthenticated plaintext connects.
+ let tlsRequired = true
+ let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
+
+ guard gateway.tlsEnabled || stored != nil else {
+ return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
+ }
+
+ if tlsRequired, stored == nil {
+ guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
+ else { return "Failed to build TLS URL for trust verification." }
+ guard let fp = await self.probeTLSFingerprint(url: url) else {
+ return "Failed to read TLS fingerprint from discovered gateway."
+ }
+ self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
+ self.pendingTrustPrompt = TrustPrompt(
+ stableID: stableID,
+ gatewayName: gateway.name,
+ host: target.host,
+ port: target.port,
+ fingerprintSha256: fp,
+ isManual: false)
+ self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
+ return nil
+ }
+
+ let tlsParams = stored.map { fp in
+ GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
+ }
+
guard let url = self.buildGatewayURL(
- host: host,
- port: port,
+ host: target.host,
+ port: target.port,
useTLS: tlsParams?.required == true)
- else { return }
- GatewaySettingsStore.saveLastGatewayConnection(
- host: host,
- port: port,
- useTLS: tlsParams?.required == true,
- stableID: gateway.stableID)
+ else { return "Failed to build discovered gateway URL." }
+ GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
self.didAutoConnect = true
self.startAutoConnect(
url: url,
- gatewayStableID: gateway.stableID,
+ gatewayStableID: stableID,
tls: tlsParams,
token: token,
password: password)
+ return nil
+ }
+
+ func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
+ _ = await self.connectWithDiagnostics(gateway)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
@@ -92,19 +166,34 @@ final class GatewayConnectionController {
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
else { return }
let stableID = self.manualStableID(host: host, port: resolvedPort)
- let tlsParams = self.resolveManualTLSParams(
- stableID: stableID,
- tlsEnabled: resolvedUseTLS,
- allowTOFUReset: self.shouldForceTLS(host: host))
+ let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
+ if resolvedUseTLS, stored == nil {
+ guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
+ guard let fp = await self.probeTLSFingerprint(url: url) else { return }
+ self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
+ self.pendingTrustPrompt = TrustPrompt(
+ stableID: stableID,
+ gatewayName: "\(host):\(resolvedPort)",
+ host: host,
+ port: resolvedPort,
+ fingerprintSha256: fp,
+ isManual: true)
+ self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
+ return
+ }
+
+ let tlsParams = stored.map { fp in
+ GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
+ }
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
- GatewaySettingsStore.saveLastGatewayConnection(
+ GatewaySettingsStore.saveLastGatewayConnectionManual(
host: host,
port: resolvedPort,
- useTLS: tlsParams?.required == true,
+ useTLS: resolvedUseTLS && tlsParams != nil,
stableID: stableID)
self.didAutoConnect = true
self.startAutoConnect(
@@ -117,36 +206,63 @@ final class GatewayConnectionController {
func connectLastKnown() async {
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
+ switch last {
+ case let .manual(host, port, useTLS, _):
+ await self.connectManual(host: host, port: port, useTLS: useTLS)
+ case let .discovered(stableID, _):
+ guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
+ await self.connectDiscoveredGateway(gateway)
+ }
+ }
+
+ func clearPendingTrustPrompt() {
+ self.pendingTrustPrompt = nil
+ self.pendingTrustConnect = nil
+ }
+
+ func acceptPendingTrustPrompt() async {
+ guard let pending = self.pendingTrustConnect,
+ let prompt = self.pendingTrustPrompt,
+ pending.stableID == prompt.stableID
+ else { return }
+
+ GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID)
+ self.clearPendingTrustPrompt()
+
+ if pending.isManual {
+ GatewaySettingsStore.saveLastGatewayConnectionManual(
+ host: prompt.host,
+ port: prompt.port,
+ useTLS: true,
+ stableID: pending.stableID)
+ } else {
+ GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true)
+ }
+
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
- let resolvedUseTLS = last.useTLS
- let tlsParams = self.resolveManualTLSParams(
- stableID: last.stableID,
- tlsEnabled: resolvedUseTLS,
- allowTOFUReset: self.shouldForceTLS(host: last.host))
- guard let url = self.buildGatewayURL(
- host: last.host,
- port: last.port,
- useTLS: tlsParams?.required == true)
- else { return }
- if resolvedUseTLS != last.useTLS {
- GatewaySettingsStore.saveLastGatewayConnection(
- host: last.host,
- port: last.port,
- useTLS: resolvedUseTLS,
- stableID: last.stableID)
- }
+ let tlsParams = GatewayTLSParams(
+ required: true,
+ expectedFingerprint: prompt.fingerprintSha256,
+ allowTOFU: false,
+ storeKey: pending.stableID)
+
self.didAutoConnect = true
self.startAutoConnect(
- url: url,
- gatewayStableID: last.stableID,
+ url: pending.url,
+ gatewayStableID: pending.stableID,
tls: tlsParams,
token: token,
password: password)
}
+ func declinePendingTrustPrompt() {
+ self.clearPendingTrustPrompt()
+ self.appModel?.gatewayStatusText = "Offline"
+ }
+
private func updateFromDiscovery() {
let newGateways = self.discovery.gateways
self.gateways = newGateways
@@ -223,25 +339,30 @@ final class GatewayConnectionController {
}
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
- let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
- let tlsParams = self.resolveManualTLSParams(
- stableID: lastKnown.stableID,
- tlsEnabled: resolvedUseTLS,
- allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
- guard let url = self.buildGatewayURL(
- host: lastKnown.host,
- port: lastKnown.port,
- useTLS: tlsParams?.required == true)
- else { return }
+ if case let .manual(host, port, useTLS, stableID) = lastKnown {
+ let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
+ let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
+ let tlsParams = stored.map { fp in
+ GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
+ }
+ guard let url = self.buildGatewayURL(
+ host: host,
+ port: port,
+ useTLS: resolvedUseTLS && tlsParams != nil)
+ else { return }
- self.didAutoConnect = true
- self.startAutoConnect(
- url: url,
- gatewayStableID: lastKnown.stableID,
- tls: tlsParams,
- token: token,
- password: password)
- return
+ // Security: autoconnect only to previously trusted gateways (stored TLS pin).
+ guard tlsParams != nil else { return }
+
+ self.didAutoConnect = true
+ self.startAutoConnect(
+ url: url,
+ gatewayStableID: stableID,
+ tls: tlsParams,
+ token: token,
+ password: password)
+ return
+ }
}
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
@@ -254,36 +375,26 @@ final class GatewayConnectionController {
self.gateways.contains(where: { $0.stableID == id })
}) {
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
- guard let host = self.resolveGatewayHost(target) else { return }
- let port = target.gatewayPort ?? 18789
- let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
- guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
- else { return }
+ // Security: autoconnect only to previously trusted gateways (stored TLS pin).
+ guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
self.didAutoConnect = true
- self.startAutoConnect(
- url: url,
- gatewayStableID: target.stableID,
- tls: tlsParams,
- token: token,
- password: password)
+ Task { [weak self] in
+ guard let self else { return }
+ await self.connectDiscoveredGateway(target)
+ }
return
}
if self.gateways.count == 1, let gateway = self.gateways.first {
- guard let host = self.resolveGatewayHost(gateway) else { return }
- let port = gateway.gatewayPort ?? 18789
- let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
- guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
- else { return }
+ // Security: autoconnect only to previously trusted gateways (stored TLS pin).
+ guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
self.didAutoConnect = true
- self.startAutoConnect(
- url: url,
- gatewayStableID: gateway.stableID,
- tls: tlsParams,
- token: token,
- password: password)
+ Task { [weak self] in
+ guard let self else { return }
+ await self.connectDiscoveredGateway(gateway)
+ }
return
}
}
@@ -339,15 +450,27 @@ final class GatewayConnectionController {
}
}
- private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
+ private func resolveDiscoveredTLSParams(
+ gateway: GatewayDiscoveryModel.DiscoveredGateway,
+ allowTOFU: Bool) -> GatewayTLSParams?
+ {
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
- if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
+ // Never let unauthenticated discovery (TXT) override a stored pin.
+ if let stored {
return GatewayTLSParams(
required: true,
- expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
- allowTOFU: stored == nil,
+ expectedFingerprint: stored,
+ allowTOFU: false,
+ storeKey: stableID)
+ }
+
+ if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
+ return GatewayTLSParams(
+ required: true,
+ expectedFingerprint: nil,
+ allowTOFU: false,
storeKey: stableID)
}
@@ -364,21 +487,154 @@ final class GatewayConnectionController {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
- allowTOFU: stored == nil || allowTOFUReset,
+ allowTOFU: false,
storeKey: stableID)
}
return nil
}
- private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
- if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
- return tailnet
+ private func probeTLSFingerprint(url: URL) async -> String? {
+ await withCheckedContinuation { continuation in
+ let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in
+ continuation.resume(returning: fp)
+ }
+ probe.start()
}
- if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
- return lanHost
+ }
+
+ private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
+ guard case let .service(name, type, domain, _) = endpoint else { return nil }
+ let key = "\(domain)|\(type)|\(name)"
+ return await withCheckedContinuation { continuation in
+ let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
+ Task { @MainActor in
+ self?.pendingServiceResolvers[key] = nil
+ continuation.resume(returning: result)
+ }
+ }
+ self.pendingServiceResolvers[key] = resolver
+ resolver.start()
+ }
+ }
+
+ private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
+ switch endpoint {
+ case let .hostPort(host, port):
+ return (host: host.debugDescription, port: Int(port.rawValue))
+ case let .service(name, type, domain, _):
+ return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
+ default:
+ return nil
+ }
+ }
+
+ private static func resolveBonjourServiceToHostPort(
+ name: String,
+ type: String,
+ domain: String,
+ timeoutSeconds: TimeInterval = 3.0
+ ) async -> (host: String, port: Int)? {
+ // NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
+ // we can end up never receiving callbacks, which in turn leaks the continuation and leaves
+ // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
+ // resume the continuation exactly once (timeout/cancel safe).
+ @MainActor
+ final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
+ private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
+ private let service: NetService
+ private var timeoutTask: Task?
+ private var finished = false
+
+ init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
+ self.cont = cont
+ self.service = service
+ super.init()
+ }
+
+ func start(timeoutSeconds: TimeInterval) {
+ self.service.delegate = self
+ self.service.schedule(in: .main, forMode: .default)
+
+ // NetService has its own timeout, but we keep a manual one as a backstop in case
+ // callbacks never arrive (e.g. local network permission issues).
+ self.timeoutTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+ let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: ns)
+ self.finish(nil)
+ }
+
+ self.service.resolve(withTimeout: timeoutSeconds)
+ }
+
+ func netServiceDidResolveAddress(_ sender: NetService) {
+ self.finish(Self.extractHostPort(sender))
+ }
+
+ func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
+ _ = errorDict // currently best-effort; callers surface a generic failure
+ self.finish(nil)
+ }
+
+ private func finish(_ result: (host: String, port: Int)?) {
+ guard !self.finished else { return }
+ self.finished = true
+
+ self.timeoutTask?.cancel()
+ self.timeoutTask = nil
+
+ self.service.stop()
+ self.service.remove(from: .main, forMode: .default)
+
+ let c = self.cont
+ self.cont = nil
+ c?.resume(returning: result)
+ }
+
+ private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
+ let port = svc.port
+
+ if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
+ return (host: host, port: port)
+ }
+
+ guard let addrs = svc.addresses else { return nil }
+ for addrData in addrs {
+ let host = addrData.withUnsafeBytes { ptr -> String? in
+ guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
+ var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
+
+ let rc = getnameinfo(
+ base.assumingMemoryBound(to: sockaddr.self),
+ socklen_t(ptr.count),
+ &buffer,
+ socklen_t(buffer.count),
+ nil,
+ 0,
+ NI_NUMERICHOST)
+ guard rc == 0 else { return nil }
+ return String(cString: buffer)
+ }
+
+ if let host, !host.isEmpty {
+ return (host: host, port: port)
+ }
+ }
+
+ return nil
+ }
+ }
+
+ return await withCheckedContinuation { cont in
+ Task { @MainActor in
+ let service = NetService(domain: domain, type: type, name: name)
+ let resolver = Resolver(cont: cont, service: service)
+ // Keep the resolver alive for the lifetime of the NetService resolve.
+ objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ resolver.start(timeoutSeconds: timeoutSeconds)
+ }
}
- return nil
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
@@ -662,5 +918,84 @@ extension GatewayConnectionController {
func _test_triggerAutoConnect() {
self.maybeAutoConnect()
}
+
+ func _test_didAutoConnect() -> Bool {
+ self.didAutoConnect
+ }
+
+ func _test_resolveDiscoveredTLSParams(
+ gateway: GatewayDiscoveryModel.DiscoveredGateway,
+ allowTOFU: Bool) -> GatewayTLSParams?
+ {
+ self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
+ }
}
#endif
+
+private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
+ private let url: URL
+ private let timeoutSeconds: Double
+ private let onComplete: (String?) -> Void
+ private var didFinish = false
+ private var session: URLSession?
+ private var task: URLSessionWebSocketTask?
+
+ init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
+ self.url = url
+ self.timeoutSeconds = timeoutSeconds
+ self.onComplete = onComplete
+ }
+
+ func start() {
+ let config = URLSessionConfiguration.ephemeral
+ config.timeoutIntervalForRequest = self.timeoutSeconds
+ config.timeoutIntervalForResource = self.timeoutSeconds
+ let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
+ self.session = session
+ let task = session.webSocketTask(with: self.url)
+ self.task = task
+ task.resume()
+
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
+ self?.finish(nil)
+ }
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
+ ) {
+ guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
+ let trust = challenge.protectionSpace.serverTrust
+ else {
+ completionHandler(.performDefaultHandling, nil)
+ return
+ }
+
+ let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
+ completionHandler(.cancelAuthenticationChallenge, nil)
+ self.finish(fp)
+ }
+
+ private func finish(_ fingerprint: String?) {
+ objc_sync_enter(self)
+ defer { objc_sync_exit(self) }
+ guard !self.didFinish else { return }
+ self.didFinish = true
+ self.task?.cancel(with: .goingAway, reason: nil)
+ self.session?.invalidateAndCancel()
+ self.onComplete(fingerprint)
+ }
+
+ private static func certificateFingerprint(_ trust: SecTrust) -> String? {
+ guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
+ let cert = chain.first
+ else {
+ return nil
+ }
+ let data = SecCertificateCopyData(cert) as Data
+ let digest = SHA256.hash(data: data)
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
new file mode 100644
index 00000000000..56d490e226b
--- /dev/null
+++ b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+enum GatewayConnectionIssue: Equatable {
+ case none
+ case tokenMissing
+ case unauthorized
+ case pairingRequired(requestId: String?)
+ case network
+ case unknown(String)
+
+ var requestId: String? {
+ if case let .pairingRequired(requestId) = self {
+ return requestId
+ }
+ return nil
+ }
+
+ var needsAuthToken: Bool {
+ switch self {
+ case .tokenMissing, .unauthorized:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var needsPairing: Bool {
+ if case .pairingRequired = self { return true }
+ return false
+ }
+
+ static func detect(from statusText: String) -> Self {
+ let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return .none }
+ let lower = trimmed.lowercased()
+
+ if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
+ return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
+ }
+ if lower.contains("gateway token missing") {
+ return .tokenMissing
+ }
+ if lower.contains("unauthorized") {
+ return .unauthorized
+ }
+ if lower.contains("connection refused") ||
+ lower.contains("timed out") ||
+ lower.contains("network is unreachable") ||
+ lower.contains("cannot find host") ||
+ lower.contains("could not connect")
+ {
+ return .network
+ }
+ if lower.hasPrefix("gateway error:") {
+ return .unknown(trimmed)
+ }
+ return .none
+ }
+
+ private static func extractRequestId(from statusText: String) -> String? {
+ let marker = "requestId:"
+ guard let range = statusText.range(of: marker) else { return nil }
+ let suffix = statusText[range.upperBound...]
+ let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
+ let end = trimmed.firstIndex(where: { ch in
+ ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
+ }) ?? trimmed.endIndex
+ let id = String(trimmed[.. String {
diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
new file mode 100644
index 00000000000..eac92df71e8
--- /dev/null
+++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
@@ -0,0 +1,113 @@
+import SwiftUI
+
+struct GatewayQuickSetupSheet: View {
+ @Environment(NodeAppModel.self) private var appModel
+ @Environment(GatewayConnectionController.self) private var gatewayController
+ @Environment(\.dismiss) private var dismiss
+
+ @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
+ @State private var connecting: Bool = false
+ @State private var connectError: String?
+
+ var body: some View {
+ NavigationStack {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Connect to a Gateway?")
+ .font(.title2.bold())
+
+ if let candidate = self.bestCandidate {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(verbatim: candidate.name)
+ .font(.headline)
+ Text(verbatim: candidate.debugID)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ VStack(alignment: .leading, spacing: 2) {
+ // Use verbatim strings so Bonjour-provided values can't be interpreted as
+ // localized format strings (which can crash with Objective-C exceptions).
+ Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
+ Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
+ Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
+ Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
+ }
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ .background(.thinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+
+ Button {
+ self.connectError = nil
+ self.connecting = true
+ Task {
+ let err = await self.gatewayController.connectWithDiagnostics(candidate)
+ await MainActor.run {
+ self.connecting = false
+ self.connectError = err
+ // If we kicked off a connect, leave the sheet up so the user can see status evolve.
+ }
+ }
+ } label: {
+ Group {
+ if self.connecting {
+ HStack(spacing: 8) {
+ ProgressView().progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect")
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(self.connecting)
+
+ if let connectError {
+ Text(connectError)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+
+ Button {
+ self.dismiss()
+ } label: {
+ Text("Not now")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .disabled(self.connecting)
+
+ Toggle("Don’t show this again", isOn: self.$quickSetupDismissed)
+ .padding(.top, 4)
+ } else {
+ Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Quick Setup")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ self.quickSetupDismissed = true
+ self.dismiss()
+ } label: {
+ Text("Close")
+ }
+ }
+ }
+ }
+ }
+
+ private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
+ // Prefer whatever discovery says is first; the list is already name-sorted.
+ self.gatewayController.gateways.first
+ }
+}
diff --git a/apps/ios/Sources/Gateway/GatewayServiceResolver.swift b/apps/ios/Sources/Gateway/GatewayServiceResolver.swift
new file mode 100644
index 00000000000..882a4e7d05a
--- /dev/null
+++ b/apps/ios/Sources/Gateway/GatewayServiceResolver.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+// NetService-based resolver for Bonjour services.
+// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
+final class GatewayServiceResolver: NSObject, NetServiceDelegate {
+ private let service: NetService
+ private let completion: ((host: String, port: Int)?) -> Void
+ private var didFinish = false
+
+ init(
+ name: String,
+ type: String,
+ domain: String,
+ completion: @escaping ((host: String, port: Int)?) -> Void)
+ {
+ self.service = NetService(domain: domain, type: type, name: name)
+ self.completion = completion
+ super.init()
+ self.service.delegate = self
+ }
+
+ func start(timeout: TimeInterval = 2.0) {
+ self.service.schedule(in: .main, forMode: .common)
+ self.service.resolve(withTimeout: timeout)
+ }
+
+ func netServiceDidResolveAddress(_ sender: NetService) {
+ let host = Self.normalizeHost(sender.hostName)
+ let port = sender.port
+ guard let host, !host.isEmpty, port > 0 else {
+ self.finish(result: nil)
+ return
+ }
+ self.finish(result: (host: host, port: port))
+ }
+
+ func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
+ self.finish(result: nil)
+ }
+
+ private func finish(result: ((host: String, port: Int))?) {
+ guard !self.didFinish else { return }
+ self.didFinish = true
+ self.service.stop()
+ self.service.remove(from: .main, forMode: .common)
+ self.completion(result)
+ }
+
+ private static func normalizeHost(_ raw: String?) -> String? {
+ let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty { return nil }
+ return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ }
+}
+
diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
index d2273865230..3ff57ad2e67 100644
--- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
+++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
@@ -4,6 +4,7 @@ import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclaw.gateway"
private static let nodeService = "ai.openclaw.node"
+ private static let talkService = "ai.openclaw.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
@@ -13,6 +14,7 @@ enum GatewaySettingsStore {
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
+ private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
@@ -23,6 +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"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
@@ -114,25 +117,113 @@ enum GatewaySettingsStore {
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
- static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
+ enum LastGatewayConnection: Equatable {
+ case manual(host: String, port: Int, useTLS: Bool, stableID: String)
+ case discovered(stableID: String, useTLS: Bool)
+
+ var stableID: String {
+ switch self {
+ case let .manual(_, _, _, stableID):
+ return stableID
+ case let .discovered(stableID, _):
+ return stableID
+ }
+ }
+
+ var useTLS: Bool {
+ switch self {
+ case let .manual(_, _, useTLS, _):
+ return useTLS
+ case let .discovered(_, useTLS):
+ return useTLS
+ }
+ }
+ }
+
+ private enum LastGatewayKind: String {
+ case manual
+ case discovered
+ }
+
+ static func loadTalkElevenLabsApiKey() -> String? {
+ let value = KeychainStore.loadString(
+ service: self.talkService,
+ account: self.talkElevenLabsApiKeyAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
+ let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty {
+ _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
+ return
+ }
+ _ = KeychainStore.saveString(
+ trimmed,
+ service: self.talkService,
+ account: self.talkElevenLabsApiKeyAccount)
+ }
+
+ static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard
+ defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
}
- static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
+ static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
let defaults = UserDefaults.standard
+ defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
+ defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
+ defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
+ }
+
+ static func loadLastGatewayConnection() -> LastGatewayConnection? {
+ let defaults = UserDefaults.standard
+ let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !stableID.isEmpty else { return nil }
+ let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
+ let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
+
+ if kind == .discovered {
+ return .discovered(stableID: stableID, useTLS: useTLS)
+ }
+
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
- let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
- let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
- return (host: host, port: port, useTLS: useTLS, stableID: stableID)
+ // Back-compat: older builds persisted manual-style host/port without a kind marker.
+ guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
+ return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
+ }
+
+ static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
+ defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
+ defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
+ }
+
+ static func deleteGatewayCredentials(instanceId: String) {
+ let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ _ = KeychainStore.delete(
+ service: self.gatewayService,
+ account: self.gatewayTokenAccount(instanceId: trimmed))
+ _ = KeychainStore.delete(
+ service: self.gatewayService,
+ account: self.gatewayPasswordAccount(instanceId: trimmed))
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift
new file mode 100644
index 00000000000..8ccbab42da7
--- /dev/null
+++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+struct GatewaySetupPayload: Codable {
+ var url: String?
+ var host: String?
+ var port: Int?
+ var tls: Bool?
+ var token: String?
+ var password: String?
+}
+
+enum GatewaySetupCode {
+ static func decode(raw: String) -> GatewaySetupPayload? {
+ if let payload = decodeFromJSON(raw) {
+ return payload
+ }
+ if let decoded = decodeBase64Payload(raw),
+ let payload = decodeFromJSON(decoded)
+ {
+ return payload
+ }
+ return nil
+ }
+
+ private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? {
+ guard let data = json.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data)
+ }
+
+ private static func decodeBase64Payload(_ raw: String) -> String? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let normalized = trimmed
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let padding = normalized.count % 4
+ let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
+ guard let data = Data(base64Encoded: padded) else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+}
+
diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
new file mode 100644
index 00000000000..eff6b71bad5
--- /dev/null
+++ b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+struct GatewayTrustPromptAlert: ViewModifier {
+ @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
+
+ private var promptBinding: Binding {
+ Binding(
+ get: { self.gatewayController.pendingTrustPrompt },
+ set: { _ in
+ // Keep pending trust state until explicit user action.
+ // `alert(item:)` may set the binding to nil during dismissal, which can race with
+ // the button handler and cause accept to no-op.
+ })
+ }
+
+ func body(content: Content) -> some View {
+ content.alert(item: self.promptBinding) { prompt in
+ Alert(
+ title: Text("Trust this gateway?"),
+ message: Text(
+ """
+ First-time TLS connection.
+
+ Verify this SHA-256 fingerprint out-of-band before trusting:
+ \(prompt.fingerprintSha256)
+ """),
+ primaryButton: .cancel(Text("Cancel")) {
+ self.gatewayController.declinePendingTrustPrompt()
+ },
+ secondaryButton: .default(Text("Trust and connect")) {
+ Task { await self.gatewayController.acceptPendingTrustPrompt() }
+ })
+ }
+ }
+}
+
+extension View {
+ func gatewayTrustPromptAlert() -> some View {
+ self.modifier(GatewayTrustPromptAlert())
+ }
+}
diff --git a/apps/ios/Sources/Gateway/TCPProbe.swift b/apps/ios/Sources/Gateway/TCPProbe.swift
new file mode 100644
index 00000000000..e22da96298f
--- /dev/null
+++ b/apps/ios/Sources/Gateway/TCPProbe.swift
@@ -0,0 +1,43 @@
+import Foundation
+import Network
+import os
+
+enum TCPProbe {
+ static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool {
+ guard port >= 1, port <= 65535 else { return false }
+ guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
+
+ let endpointHost = NWEndpoint.Host(host)
+ let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
+
+ return await withCheckedContinuation { cont in
+ let queue = DispatchQueue(label: queueLabel)
+ let finished = OSAllocatedUnfairLock(initialState: false)
+ let finish: @Sendable (Bool) -> Void = { ok in
+ let shouldResume = finished.withLock { flag -> Bool in
+ if flag { return false }
+ flag = true
+ return true
+ }
+ guard shouldResume else { return }
+ connection.cancel()
+ cont.resume(returning: ok)
+ }
+
+ connection.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ finish(true)
+ case .failed, .cancelled:
+ finish(false)
+ default:
+ break
+ }
+ }
+
+ connection.start(queue: queue)
+ queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
+ }
+ }
+}
+
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index fe3c9ba4ed8..3182e43d30a 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -17,13 +17,13 @@
CFBundleName
$(PRODUCT_NAME)
CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 2026.2.13
- CFBundleVersion
- 20260213
- NSAppTransportSecurity
-
+ APPL
+ CFBundleShortVersionString
+ 2026.2.16
+ CFBundleVersion
+ 20260216
+ NSAppTransportSecurity
+
NSAllowsArbitraryLoadsInWebContent
diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift
index 99265d02e89..f1f0f69ed7f 100644
--- a/apps/ios/Sources/Location/LocationService.swift
+++ b/apps/ios/Sources/Location/LocationService.swift
@@ -12,6 +12,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation?
private var locationContinuation: CheckedContinuation?
+ private var updatesContinuation: AsyncStream.Continuation?
+ private var isStreaming = false
+ private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
+ private var isMonitoringSignificantChanges = false
override init() {
super.init()
@@ -104,6 +108,56 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
}
}
+ func startLocationUpdates(
+ desiredAccuracy: OpenClawLocationAccuracy,
+ significantChangesOnly: Bool) -> AsyncStream
+ {
+ self.stopLocationUpdates()
+
+ self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
+ self.manager.pausesLocationUpdatesAutomatically = true
+ self.manager.allowsBackgroundLocationUpdates = true
+
+ self.isStreaming = true
+ if significantChangesOnly {
+ self.manager.startMonitoringSignificantLocationChanges()
+ } else {
+ self.manager.startUpdatingLocation()
+ }
+
+ return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
+ self.updatesContinuation = continuation
+ continuation.onTermination = { @Sendable _ in
+ Task { @MainActor in
+ self.stopLocationUpdates()
+ }
+ }
+ }
+ }
+
+ func stopLocationUpdates() {
+ guard self.isStreaming else { return }
+ self.isStreaming = false
+ self.manager.stopUpdatingLocation()
+ self.manager.stopMonitoringSignificantLocationChanges()
+ self.updatesContinuation?.finish()
+ self.updatesContinuation = nil
+ }
+
+ func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
+ self.significantLocationCallback = onUpdate
+ guard !self.isMonitoringSignificantChanges else { return }
+ self.isMonitoringSignificantChanges = true
+ self.manager.startMonitoringSignificantLocationChanges()
+ }
+
+ func stopMonitoringSignificantLocationChanges() {
+ guard self.isMonitoringSignificantChanges else { return }
+ self.isMonitoringSignificantChanges = false
+ self.significantLocationCallback = nil
+ self.manager.stopMonitoringSignificantLocationChanges()
+ }
+
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
@@ -117,12 +171,22 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let locs = locations
Task { @MainActor in
- guard let cont = self.locationContinuation else { return }
- self.locationContinuation = nil
- if let latest = locs.last {
- cont.resume(returning: latest)
- } else {
- cont.resume(throwing: Error.unavailable)
+ // Resolve the one-shot continuation first (if any).
+ if let cont = self.locationContinuation {
+ self.locationContinuation = nil
+ if let latest = locs.last {
+ cont.resume(returning: latest)
+ } else {
+ cont.resume(throwing: Error.unavailable)
+ }
+ // Don't return — also forward to significant-change callback below
+ // so both consumers receive updates when both are active.
+ }
+ if let callback = self.significantLocationCallback, let latest = locs.last {
+ callback(latest)
+ }
+ if let latest = locs.last, let updates = self.updatesContinuation {
+ updates.yield(latest)
}
}
}
diff --git a/apps/ios/Sources/Location/SignificantLocationMonitor.swift b/apps/ios/Sources/Location/SignificantLocationMonitor.swift
new file mode 100644
index 00000000000..f12a157dc69
--- /dev/null
+++ b/apps/ios/Sources/Location/SignificantLocationMonitor.swift
@@ -0,0 +1,38 @@
+import CoreLocation
+import Foundation
+import OpenClawKit
+
+/// Monitors significant location changes and pushes `location.update`
+/// events to the gateway so the severance hook can determine whether
+/// the user is at their configured work location.
+@MainActor
+enum SignificantLocationMonitor {
+ static func startIfNeeded(
+ locationService: any LocationServicing,
+ locationMode: OpenClawLocationMode,
+ gateway: GatewayNodeSession
+ ) {
+ guard locationMode == .always else { return }
+ let status = locationService.authorizationStatus()
+ guard status == .authorizedAlways else { return }
+ locationService.startMonitoringSignificantLocationChanges { location in
+ struct Payload: Codable {
+ var lat: Double
+ var lon: Double
+ var accuracyMeters: Double
+ var source: String?
+ }
+ let payload = Payload(
+ lat: location.coordinate.latitude,
+ lon: location.coordinate.longitude,
+ accuracyMeters: location.horizontalAccuracy,
+ source: "ios-significant-location")
+ guard let data = try? JSONEncoder().encode(payload),
+ let json = String(data: data, encoding: .utf8)
+ else { return }
+ Task { @MainActor in
+ await gateway.sendEvent(event: "location.update", payloadJSON: json)
+ }
+ }
+ }
+}
diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
index 372f8361d30..e8dce2cd30c 100644
--- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
+++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
@@ -61,37 +61,10 @@ extension NodeAppModel {
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
- guard portInt >= 1, portInt <= 65535 else { return false }
- guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
-
- let endpointHost = NWEndpoint.Host(host)
- let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
- return await withCheckedContinuation { cont in
- let queue = DispatchQueue(label: "a2ui.preflight")
- let finished = OSAllocatedUnfairLock(initialState: false)
- let finish: @Sendable (Bool) -> Void = { ok in
- let shouldResume = finished.withLock { flag -> Bool in
- if flag { return false }
- flag = true
- return true
- }
- guard shouldResume else { return }
- connection.cancel()
- cont.resume(returning: ok)
- }
-
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- finish(true)
- case .failed, .cancelled:
- finish(false)
- default:
- break
- }
- }
- connection.start(queue: queue)
- queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
- }
+ return await TCPProbe.probe(
+ host: host,
+ port: portInt,
+ timeoutSeconds: timeoutSeconds,
+ queueLabel: "a2ui.preflight")
}
}
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 0ca521ccc60..75950f55a45 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -10,7 +10,6 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
-
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch: @unchecked Sendable {
private let lock = NSLock()
@@ -37,7 +36,6 @@ private final class NotificationInvokeLatch: @unchecked Sendable {
cont?.resume(returning: response)
}
}
-
@MainActor
@Observable
final class NodeAppModel {
@@ -53,10 +51,17 @@ final class NodeAppModel {
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
+ var nodeStatusText: String = "Offline"
+ var operatorStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var gatewayAutoReconnectEnabled: Bool = true
+ // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
+ // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
+ // multiple pending requests and cause the onboarding UI to "flip-flop".
+ var gatewayPairingPaused: Bool = false
+ var gatewayPairingRequestId: String?
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
var selectedAgentId: String?
@@ -109,6 +114,7 @@ final class NodeAppModel {
private var talkVoiceWakeSuspended = false
private var backgroundVoiceWakeSuspended = false
private var backgroundTalkSuspended = false
+ private var backgroundTalkKeptActive = false
private var backgroundedAt: Date?
private var reconnectAfterBackgroundArmed = false
@@ -264,15 +270,18 @@ final class NodeAppModel {
func setScenePhase(_ phase: ScenePhase) {
+ let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
switch phase {
case .background:
self.isBackgrounded = true
self.stopGatewayHealthMonitor()
self.backgroundedAt = Date()
self.reconnectAfterBackgroundArmed = true
- // Be conservative: release the mic when the app backgrounds.
+ // Release voice wake mic in background.
self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
- self.backgroundTalkSuspended = self.talkMode.suspendForBackground()
+ let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled
+ self.backgroundTalkKeptActive = shouldKeepTalkActive
+ self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
case .active, .inactive:
self.isBackgrounded = false
if self.operatorConnected {
@@ -284,8 +293,12 @@ final class NodeAppModel {
Task { [weak self] in
guard let self else { return }
let suspended = await MainActor.run { self.backgroundTalkSuspended }
- await MainActor.run { self.backgroundTalkSuspended = false }
- await self.talkMode.resumeAfterBackground(wasSuspended: suspended)
+ let keptActive = await MainActor.run { self.backgroundTalkKeptActive }
+ await MainActor.run {
+ self.backgroundTalkSuspended = false
+ self.backgroundTalkKeptActive = false
+ }
+ await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
}
}
if phase == .active, self.reconnectAfterBackgroundArmed {
@@ -340,6 +353,7 @@ final class NodeAppModel {
}
func setTalkEnabled(_ enabled: Bool) {
+ UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
// When talk is enabled from the UI, prioritize talk and pause voice wake.
@@ -351,6 +365,11 @@ final class NodeAppModel {
self.talkVoiceWakeSuspended = false
}
self.talkMode.setEnabled(enabled)
+ Task { [weak self] in
+ await self?.pushTalkModeToGateway(
+ enabled: enabled,
+ phase: enabled ? "enabled" : "disabled")
+ }
}
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
@@ -479,16 +498,49 @@ final class NodeAppModel {
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
- guard evt.event == "voicewake.changed" else { continue }
guard let payload = evt.payload else { continue }
- struct Payload: Decodable { var triggers: [String] }
- guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
- let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
- VoiceWakePreferences.saveTriggerWords(triggers)
+ switch evt.event {
+ case "voicewake.changed":
+ struct Payload: Decodable { var triggers: [String] }
+ guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
+ let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
+ VoiceWakePreferences.saveTriggerWords(triggers)
+ case "talk.mode":
+ struct Payload: Decodable {
+ var enabled: Bool
+ var phase: String?
+ }
+ guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
+ self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
+ default:
+ continue
+ }
}
}
}
+ private func applyTalkModeSync(enabled: Bool, phase: String?) {
+ _ = phase
+ guard self.talkMode.isEnabled != enabled else { return }
+ self.setTalkEnabled(enabled)
+ }
+
+ private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
+ guard await self.isOperatorConnected() else { return }
+ struct TalkModePayload: Encodable {
+ var enabled: Bool
+ var phase: String?
+ }
+ let payload = TalkModePayload(enabled: enabled, phase: phase)
+ guard let data = try? JSONEncoder().encode(payload),
+ let json = String(data: data, encoding: .utf8)
+ else { return }
+ _ = try? await self.operatorGateway.request(
+ method: "talk.mode",
+ paramsJSON: json,
+ timeoutSeconds: 8)
+ }
+
private func startGatewayHealthMonitor() {
self.gatewayHealthMonitorDisabled = false
self.gatewayHealthMonitor.start(
@@ -577,6 +629,8 @@ final class NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
+ case .gateway:
+ break
}
}
@@ -1506,6 +1560,8 @@ extension NodeAppModel {
func disconnectGateway() {
self.gatewayAutoReconnectEnabled = false
+ self.gatewayPairingPaused = false
+ self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1535,6 +1591,8 @@ extension NodeAppModel {
private extension NodeAppModel {
func prepareForGatewayConnect(url: URL, stableID: String) {
self.gatewayAutoReconnectEnabled = true
+ self.gatewayPairingPaused = false
+ self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -1564,6 +1622,10 @@ private extension NodeAppModel {
guard let self else { return }
var attempt = 0
while !Task.isCancelled {
+ if self.gatewayPairingPaused {
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ continue
+ }
if await self.isOperatorConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1639,8 +1701,13 @@ private extension NodeAppModel {
var attempt = 0
var currentOptions = nodeOptions
var didFallbackClientId = false
+ var pausedForPairingApproval = false
while !Task.isCancelled {
+ if self.gatewayPairingPaused {
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ continue
+ }
if await self.isGatewayConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1669,12 +1736,13 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
- GatewayDiagnostics.log(
- "gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
+ GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
if let addr = await self.nodeGateway.currentRemoteAddress() {
await MainActor.run { self.gatewayRemoteAddress = addr }
}
await self.showA2UIOnConnectIfNeeded()
+ await self.onNodeGatewayConnected()
+ await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) }
},
onDisconnected: { [weak self] reason in
guard let self else { return }
@@ -1726,11 +1794,52 @@ private extension NodeAppModel {
self.showLocalCanvasOnDisconnect()
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
+
+ // If pairing is required, stop reconnect churn. The user must approve the request
+ // on the gateway before another connect attempt will succeed, and retry loops can
+ // generate multiple pending requests.
+ let lower = error.localizedDescription.lowercased()
+ if lower.contains("not_paired") || lower.contains("pairing required") {
+ let requestId: String? = {
+ // GatewayResponseError for connect decorates the message with `(requestId: ...)`.
+ // Keep this resilient since other layers may wrap the text.
+ let text = error.localizedDescription
+ guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
+ guard let end = text[start...].firstIndex(of: ")") else { return nil }
+ let raw = String(text[start.. String? {
@@ -1775,6 +1884,17 @@ private extension NodeAppModel {
}
}
+extension NodeAppModel {
+ func reloadTalkConfig() {
+ Task { [weak self] in
+ await self?.talkMode.reloadConfig()
+ }
+ }
+
+ /// Back-compat hook retained for older gateway-connect flows.
+ func onNodeGatewayConnected() async {}
+}
+
#if DEBUG
extension NodeAppModel {
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -1808,5 +1928,9 @@ extension NodeAppModel {
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
+
+ func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
+ self.applyTalkModeSync(enabled: enabled, phase: phase)
+ }
}
#endif
diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
index 18eac23e281..bf6c0ba2d18 100644
--- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
+++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
@@ -21,6 +21,7 @@ struct GatewayOnboardingView: View {
}
.navigationTitle("Connect Gateway")
}
+ .gatewayTrustPromptAlert()
}
}
@@ -256,15 +257,6 @@ private struct ManualEntryStep: View {
self.manualPassword = ""
}
- private struct SetupPayload: Codable {
- var url: String?
- var host: String?
- var port: Int?
- var tls: Bool?
- var token: String?
- var password: String?
- }
-
private func applySetupCode() {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
@@ -272,7 +264,7 @@ private struct ManualEntryStep: View {
return
}
- guard let payload = self.decodeSetupPayload(raw: raw) else {
+ guard let payload = GatewaySetupCode.decode(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
return
}
@@ -322,34 +314,7 @@ private struct ManualEntryStep: View {
}
}
- private func decodeSetupPayload(raw: String) -> SetupPayload? {
- if let payload = decodeSetupPayloadFromJSON(raw) {
- return payload
- }
- if let decoded = decodeBase64Payload(raw),
- let payload = decodeSetupPayloadFromJSON(decoded)
- {
- return payload
- }
- return nil
- }
-
- private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
- guard let data = json.data(using: .utf8) else { return nil }
- return try? JSONDecoder().decode(SetupPayload.self, from: data)
- }
-
- private func decodeBase64Payload(_ raw: String) -> String? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- let normalized = trimmed
- .replacingOccurrences(of: "-", with: "+")
- .replacingOccurrences(of: "_", with: "/")
- let padding = normalized.count % 4
- let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
- guard let data = Data(base64Encoded: padded) else { return nil }
- return String(data: data, encoding: .utf8)
- }
+ // (GatewaySetupCode) decode raw setup codes.
}
private struct ConnectionStatusBox: View {
diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift
new file mode 100644
index 00000000000..9822ac1706f
--- /dev/null
+++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+enum OnboardingConnectionMode: String, CaseIterable {
+ case homeNetwork = "home_network"
+ case remoteDomain = "remote_domain"
+ case developerLocal = "developer_local"
+
+ var title: String {
+ switch self {
+ case .homeNetwork:
+ "Home Network"
+ case .remoteDomain:
+ "Remote Domain"
+ case .developerLocal:
+ "Same Machine (Dev)"
+ }
+ }
+}
+
+enum OnboardingStateStore {
+ private static let completedDefaultsKey = "onboarding.completed"
+ private static let lastModeDefaultsKey = "onboarding.last_mode"
+ private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
+
+ @MainActor
+ static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
+ if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
+ // If we have a last-known connection config, don't force onboarding on launch. Auto-connect
+ // should handle reconnecting, and users can always open onboarding manually if needed.
+ if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
+ return appModel.gatewayServerName == nil
+ }
+
+ static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
+ defaults.set(true, forKey: Self.completedDefaultsKey)
+ if let mode {
+ defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
+ }
+ defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
+ }
+
+ static func markIncomplete(defaults: UserDefaults = .standard) {
+ defaults.set(false, forKey: Self.completedDefaultsKey)
+ }
+
+ static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
+ let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !raw.isEmpty else { return nil }
+ return OnboardingConnectionMode(rawValue: raw)
+ }
+}
diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
new file mode 100644
index 00000000000..7320099f19a
--- /dev/null
+++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
@@ -0,0 +1,852 @@
+import CoreImage
+import OpenClawKit
+import PhotosUI
+import SwiftUI
+import UIKit
+
+private enum OnboardingStep: Int, CaseIterable {
+ case welcome
+ case mode
+ case connect
+ case auth
+ case success
+
+ var previous: Self? {
+ Self(rawValue: self.rawValue - 1)
+ }
+
+ var next: Self? {
+ Self(rawValue: self.rawValue + 1)
+ }
+
+ /// Progress label for the manual setup flow (mode → connect → auth → success).
+ var manualProgressTitle: String {
+ let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
+ guard let idx = manualSteps.firstIndex(of: self) else { return "" }
+ return "Step \(idx + 1) of \(manualSteps.count)"
+ }
+
+ var title: String {
+ switch self {
+ case .welcome: "Welcome"
+ case .mode: "Connection Mode"
+ case .connect: "Connect"
+ case .auth: "Authentication"
+ case .success: "Connected"
+ }
+ }
+
+ var canGoBack: Bool {
+ self != .welcome && self != .success
+ }
+}
+
+struct OnboardingWizardView: View {
+ @Environment(NodeAppModel.self) private var appModel: NodeAppModel
+ @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
+ @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
+ @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
+ @AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
+ @State private var step: OnboardingStep = .welcome
+ @State private var selectedMode: OnboardingConnectionMode?
+ @State private var manualHost: String = ""
+ @State private var manualPort: Int = 18789
+ @State private var manualPortText: String = "18789"
+ @State private var manualTLS: Bool = true
+ @State private var gatewayToken: String = ""
+ @State private var gatewayPassword: String = ""
+ @State private var connectMessage: String?
+ @State private var statusLine: String = "Scan the QR code from your gateway to connect."
+ @State private var connectingGatewayID: String?
+ @State private var issue: GatewayConnectionIssue = .none
+ @State private var didMarkCompleted = false
+ @State private var didAutoPresentQR = false
+ @State private var pairingRequestId: String?
+ @State private var discoveryRestartTask: Task?
+ @State private var showQRScanner: Bool = false
+ @State private var scannerError: String?
+ @State private var selectedPhoto: PhotosPickerItem?
+
+ let allowSkip: Bool
+ let onClose: () -> Void
+
+ private var isFullScreenStep: Bool {
+ self.step == .welcome || self.step == .success
+ }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ switch self.step {
+ case .welcome:
+ self.welcomeStep
+ case .success:
+ self.successStep
+ default:
+ Form {
+ switch self.step {
+ case .mode:
+ self.modeStep
+ case .connect:
+ self.connectStep
+ case .auth:
+ self.authStep
+ default:
+ EmptyView()
+ }
+ }
+ .scrollDismissesKeyboard(.interactively)
+ }
+ }
+ .navigationTitle(self.isFullScreenStep ? "" : self.step.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if !self.isFullScreenStep {
+ ToolbarItem(placement: .principal) {
+ VStack(spacing: 2) {
+ Text(self.step.title)
+ .font(.headline)
+ Text(self.step.manualProgressTitle)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ ToolbarItem(placement: .topBarLeading) {
+ if self.step.canGoBack {
+ Button {
+ self.navigateBack()
+ } label: {
+ Label("Back", systemImage: "chevron.left")
+ }
+ } else if self.allowSkip {
+ Button("Close") {
+ self.onClose()
+ }
+ }
+ }
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button("Done") {
+ UIApplication.shared.sendAction(
+ #selector(UIResponder.resignFirstResponder),
+ to: nil, from: nil, for: nil)
+ }
+ }
+ }
+ }
+ .gatewayTrustPromptAlert()
+ .alert("QR Scanner Unavailable", isPresented: Binding(
+ get: { self.scannerError != nil },
+ set: { if !$0 { self.scannerError = nil } }
+ )) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(self.scannerError ?? "")
+ }
+ .sheet(isPresented: self.$showQRScanner) {
+ NavigationStack {
+ QRScannerView(
+ onGatewayLink: { link in
+ self.handleScannedLink(link)
+ },
+ onError: { error in
+ self.showQRScanner = false
+ self.statusLine = "Scanner error: \(error)"
+ self.scannerError = error
+ },
+ onDismiss: {
+ self.showQRScanner = false
+ })
+ .ignoresSafeArea()
+ .navigationTitle("Scan QR Code")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button("Cancel") { self.showQRScanner = false }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
+ Label("Photos", systemImage: "photo")
+ }
+ }
+ }
+ }
+ .onChange(of: self.selectedPhoto) { _, newValue in
+ guard let item = newValue else { return }
+ self.selectedPhoto = nil
+ Task {
+ guard let data = try? await item.loadTransferable(type: Data.self) else {
+ self.showQRScanner = false
+ self.scannerError = "Could not load the selected image."
+ return
+ }
+ if let message = self.detectQRCode(from: data) {
+ if let link = GatewayConnectDeepLink.fromSetupCode(message) {
+ self.handleScannedLink(link)
+ return
+ }
+ if let url = URL(string: message),
+ let route = DeepLinkParser.parse(url),
+ case let .gateway(link) = route
+ {
+ self.handleScannedLink(link)
+ return
+ }
+ }
+ self.showQRScanner = false
+ self.scannerError = "No valid QR code found in the selected image."
+ }
+ }
+ }
+ .onAppear {
+ self.initializeState()
+ }
+ .onDisappear {
+ self.discoveryRestartTask?.cancel()
+ self.discoveryRestartTask = nil
+ }
+ .onChange(of: self.discoveryDomain) { _, _ in
+ self.scheduleDiscoveryRestart()
+ }
+ .onChange(of: self.manualPortText) { _, newValue in
+ let digits = newValue.filter(\.isNumber)
+ if digits != newValue {
+ self.manualPortText = digits
+ return
+ }
+ guard let parsed = Int(digits), parsed > 0 else {
+ self.manualPort = 0
+ return
+ }
+ self.manualPort = min(parsed, 65535)
+ }
+ .onChange(of: self.manualPort) { _, newValue in
+ let normalized = newValue > 0 ? String(newValue) : ""
+ if self.manualPortText != normalized {
+ self.manualPortText = normalized
+ }
+ }
+ .onChange(of: self.gatewayToken) { _, newValue in
+ self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
+ }
+ .onChange(of: self.gatewayPassword) { _, newValue in
+ self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
+ }
+ .onChange(of: self.appModel.gatewayStatusText) { _, newValue in
+ let next = GatewayConnectionIssue.detect(from: newValue)
+ // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
+ // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
+ if self.issue.needsPairing, next.needsPairing {
+ // Keep the requestId sticky even if the status line omits it after we pause.
+ let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
+ self.issue = .pairingRequired(requestId: mergedRequestId)
+ } else if self.issue.needsPairing, !next.needsPairing {
+ // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
+ } else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
+ // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
+ // the user retries/scans again or we successfully connect.
+ } else {
+ self.issue = next
+ }
+
+ if let requestId = next.requestId, !requestId.isEmpty {
+ self.pairingRequestId = requestId
+ }
+
+ // If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
+ if next.needsAuthToken {
+ self.appModel.gatewayAutoReconnectEnabled = false
+ }
+
+ if self.issue.needsAuthToken || self.issue.needsPairing {
+ self.step = .auth
+ }
+ if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ self.connectMessage = newValue
+ self.statusLine = newValue
+ }
+ }
+ .onChange(of: self.appModel.gatewayServerName) { _, newValue in
+ guard newValue != nil else { return }
+ self.statusLine = "Connected."
+ if !self.didMarkCompleted, let selectedMode {
+ OnboardingStateStore.markCompleted(mode: selectedMode)
+ self.didMarkCompleted = true
+ }
+ self.onClose()
+ }
+ }
+
+ @ViewBuilder
+ private var welcomeStep: some View {
+ VStack(spacing: 0) {
+ Spacer()
+
+ Image(systemName: "qrcode.viewfinder")
+ .font(.system(size: 64))
+ .foregroundStyle(.tint)
+ .padding(.bottom, 20)
+
+ Text("Welcome")
+ .font(.largeTitle.weight(.bold))
+ .padding(.bottom, 8)
+
+ Text("Connect to your OpenClaw gateway")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+
+ Spacer()
+
+ VStack(spacing: 12) {
+ Button {
+ self.statusLine = "Opening QR scanner…"
+ self.showQRScanner = true
+ } label: {
+ Label("Scan QR Code", systemImage: "qrcode")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Button {
+ self.step = .mode
+ } label: {
+ Text("Set Up Manually")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ }
+ .padding(.bottom, 12)
+
+ Text(self.statusLine)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 24)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 48)
+ }
+ }
+
+ @ViewBuilder
+ private var modeStep: some View {
+ Section("Connection Mode") {
+ OnboardingModeRow(
+ title: OnboardingConnectionMode.homeNetwork.title,
+ subtitle: "LAN or Tailscale host",
+ selected: self.selectedMode == .homeNetwork)
+ {
+ self.selectMode(.homeNetwork)
+ }
+
+ OnboardingModeRow(
+ title: OnboardingConnectionMode.remoteDomain.title,
+ subtitle: "VPS with domain",
+ selected: self.selectedMode == .remoteDomain)
+ {
+ self.selectMode(.remoteDomain)
+ }
+
+ Toggle(
+ "Developer mode",
+ isOn: Binding(
+ get: { self.developerModeEnabled },
+ set: { newValue in
+ self.developerModeEnabled = newValue
+ if !newValue, self.selectedMode == .developerLocal {
+ self.selectedMode = nil
+ }
+ }))
+
+ if self.developerModeEnabled {
+ OnboardingModeRow(
+ title: OnboardingConnectionMode.developerLocal.title,
+ subtitle: "For local iOS app development",
+ selected: self.selectedMode == .developerLocal)
+ {
+ self.selectMode(.developerLocal)
+ }
+ }
+ }
+
+ Section {
+ Button("Continue") {
+ self.step = .connect
+ }
+ .disabled(self.selectedMode == nil)
+ }
+ }
+
+ @ViewBuilder
+ private var connectStep: some View {
+ if let selectedMode {
+ Section {
+ LabeledContent("Mode", value: selectedMode.title)
+ LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
+ LabeledContent("Status", value: self.appModel.gatewayStatusText)
+ LabeledContent("Progress", value: self.statusLine)
+ } header: {
+ Text("Status")
+ } footer: {
+ if let connectMessage {
+ Text(connectMessage)
+ }
+ }
+
+ switch selectedMode {
+ case .homeNetwork:
+ self.homeNetworkConnectSection
+ case .remoteDomain:
+ self.remoteDomainConnectSection
+ case .developerLocal:
+ self.developerConnectSection
+ }
+ } else {
+ Section {
+ Text("Choose a mode first.")
+ Button("Back to Mode Selection") {
+ self.step = .mode
+ }
+ }
+ }
+ }
+
+ private var homeNetworkConnectSection: some View {
+ Group {
+ Section("Discovered Gateways") {
+ if self.gatewayController.gateways.isEmpty {
+ Text("No gateways found yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(self.gatewayController.gateways) { gateway in
+ let hasHost = self.gatewayHasResolvableHost(gateway)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(gateway.name)
+ if let host = gateway.lanHost ?? gateway.tailnetDns {
+ Text(host)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Button {
+ Task { await self.connectDiscoveredGateway(gateway) }
+ } label: {
+ if self.connectingGatewayID == gateway.id {
+ ProgressView()
+ .progressViewStyle(.circular)
+ } else if !hasHost {
+ Text("Resolving…")
+ } else {
+ Text("Connect")
+ }
+ }
+ .disabled(self.connectingGatewayID != nil || !hasHost)
+ }
+ }
+ }
+
+ Button("Restart Discovery") {
+ self.gatewayController.restartDiscovery()
+ }
+ .disabled(self.connectingGatewayID != nil)
+ }
+
+ self.manualConnectionFieldsSection(title: "Manual Fallback")
+ }
+ }
+
+ private var remoteDomainConnectSection: some View {
+ self.manualConnectionFieldsSection(title: "Domain Settings")
+ }
+
+ private var developerConnectSection: some View {
+ Section {
+ TextField("Host", text: self.$manualHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Port", text: self.$manualPortText)
+ .keyboardType(.numberPad)
+ Toggle("Use TLS", isOn: self.$manualTLS)
+
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect")
+ }
+ }
+ .disabled(!self.canConnectManual || self.connectingGatewayID != nil)
+ } header: {
+ Text("Developer Local")
+ } footer: {
+ Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
+ }
+ }
+
+ private var authStep: some View {
+ Group {
+ Section("Authentication") {
+ TextField("Gateway Auth Token", text: self.$gatewayToken)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ SecureField("Gateway Password", text: self.$gatewayPassword)
+
+ if self.issue.needsAuthToken {
+ Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("Auth token looks valid.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if self.issue.needsPairing {
+ Section {
+ Button("Copy: openclaw devices list") {
+ UIPasteboard.general.string = "openclaw devices list"
+ }
+
+ if let id = self.issue.requestId {
+ Button("Copy: openclaw devices approve \(id)") {
+ UIPasteboard.general.string = "openclaw devices approve \(id)"
+ }
+ } else {
+ Button("Copy: openclaw devices approve ") {
+ UIPasteboard.general.string = "openclaw devices approve "
+ }
+ }
+ } header: {
+ Text("Pairing Approval")
+ } footer: {
+ Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
+ }
+ }
+
+ Section {
+ Button {
+ Task { await self.retryLastAttempt() }
+ } label: {
+ if self.connectingGatewayID == "retry" {
+ ProgressView()
+ .progressViewStyle(.circular)
+ } else {
+ Text("Retry Connection")
+ }
+ }
+ .disabled(self.connectingGatewayID != nil)
+
+ Button {
+ self.resumeAfterPairingApproval()
+ } label: {
+ Label("Resume After Approval", systemImage: "arrow.clockwise")
+ }
+ .disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
+
+ Button {
+ self.openQRScannerFromOnboarding()
+ } label: {
+ Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
+ }
+ .disabled(self.connectingGatewayID != nil)
+ }
+ }
+ }
+
+ private var successStep: some View {
+ VStack(spacing: 0) {
+ Spacer()
+
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.green)
+ .padding(.bottom, 20)
+
+ Text("Connected")
+ .font(.largeTitle.weight(.bold))
+ .padding(.bottom, 8)
+
+ let server = self.appModel.gatewayServerName ?? "gateway"
+ Text(server)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+
+ if let addr = self.appModel.gatewayRemoteAddress {
+ Text(addr)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button {
+ self.onClose()
+ } label: {
+ Text("Open OpenClaw")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 48)
+ }
+ }
+
+ @ViewBuilder
+ private func manualConnectionFieldsSection(title: String) -> some View {
+ Section(title) {
+ TextField("Host", text: self.$manualHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Port", text: self.$manualPortText)
+ .keyboardType(.numberPad)
+ Toggle("Use TLS", isOn: self.$manualTLS)
+ TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect")
+ }
+ }
+ .disabled(!self.canConnectManual || self.connectingGatewayID != nil)
+ }
+ }
+
+ private func handleScannedLink(_ link: GatewayConnectDeepLink) {
+ self.manualHost = link.host
+ self.manualPort = link.port
+ self.manualTLS = link.tls
+ if let token = link.token {
+ self.gatewayToken = token
+ }
+ if let password = link.password {
+ self.gatewayPassword = password
+ }
+ self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
+ self.showQRScanner = false
+ self.connectMessage = "Connecting via QR code…"
+ self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…"
+ if self.selectedMode == nil {
+ self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
+ }
+ Task { await self.connectManual() }
+ }
+
+ private func openQRScannerFromOnboarding() {
+ // Stop active reconnect loops before scanning new credentials.
+ self.appModel.disconnectGateway()
+ self.connectingGatewayID = nil
+ self.connectMessage = nil
+ self.issue = .none
+ self.pairingRequestId = nil
+ self.statusLine = "Opening QR scanner…"
+ self.showQRScanner = true
+ }
+
+ private func resumeAfterPairingApproval() {
+ // We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
+ self.appModel.gatewayAutoReconnectEnabled = true
+ self.appModel.gatewayPairingPaused = false
+ self.connectMessage = "Retrying after approval…"
+ self.statusLine = "Retrying after approval…"
+ Task { await self.retryLastAttempt() }
+ }
+
+ private func detectQRCode(from data: Data) -> String? {
+ guard let ciImage = CIImage(data: data) else { return nil }
+ let detector = CIDetector(
+ ofType: CIDetectorTypeQRCode, context: nil,
+ options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
+ let features = detector?.features(in: ciImage) ?? []
+ for feature in features {
+ if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
+ return message
+ }
+ }
+ return nil
+ }
+
+ private func navigateBack() {
+ guard let target = self.step.previous else { return }
+ self.connectingGatewayID = nil
+ self.connectMessage = nil
+ self.step = target
+ }
+ private var canConnectManual: Bool {
+ let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
+ }
+
+ private func initializeState() {
+ if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ if let last = GatewaySettingsStore.loadLastGatewayConnection() {
+ switch last {
+ case let .manual(host, port, useTLS, _):
+ self.manualHost = host
+ self.manualPort = port
+ self.manualTLS = useTLS
+ case .discovered:
+ self.manualHost = "openclaw.local"
+ self.manualPort = 18789
+ self.manualTLS = true
+ }
+ } else {
+ self.manualHost = "openclaw.local"
+ self.manualPort = 18789
+ self.manualTLS = true
+ }
+ }
+ self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
+ if self.selectedMode == nil {
+ self.selectedMode = OnboardingStateStore.lastMode()
+ }
+ if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
+ self.manualHost = "localhost"
+ self.manualTLS = false
+ }
+
+ let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedInstanceId.isEmpty {
+ self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
+ self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
+ }
+
+ let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
+ let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
+ self.didAutoPresentQR = true
+ self.statusLine = "No saved pairing found. Scan QR code to connect."
+ self.showQRScanner = true
+ }
+ }
+
+ private func scheduleDiscoveryRestart() {
+ self.discoveryRestartTask?.cancel()
+ self.discoveryRestartTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 350_000_000)
+ guard !Task.isCancelled else { return }
+ self.gatewayController.restartDiscovery()
+ }
+ }
+
+ private func saveGatewayCredentials(token: String, password: String) {
+ let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedInstanceId.isEmpty else { return }
+ let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
+ let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
+ GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
+ }
+
+ private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
+ self.connectingGatewayID = gateway.id
+ self.issue = .none
+ self.connectMessage = "Connecting to \(gateway.name)…"
+ self.statusLine = "Connecting to \(gateway.name)…"
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connect(gateway)
+ }
+
+ private func selectMode(_ mode: OnboardingConnectionMode) {
+ self.selectedMode = mode
+ self.applyModeDefaults(mode)
+ }
+
+ private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
+ let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
+
+ switch mode {
+ case .homeNetwork:
+ if hostIsDefaultLike { self.manualHost = "openclaw.local" }
+ self.manualTLS = true
+ if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
+ case .remoteDomain:
+ if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
+ self.manualTLS = true
+ if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
+ case .developerLocal:
+ if hostIsDefaultLike { self.manualHost = "localhost" }
+ self.manualTLS = false
+ if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
+ }
+ }
+
+ private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
+ let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !lanHost.isEmpty { return true }
+ let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return !tailnetDns.isEmpty
+ }
+
+ private func connectManual() async {
+ let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
+ self.connectingGatewayID = "manual"
+ self.issue = .none
+ self.connectMessage = "Connecting to \(host)…"
+ self.statusLine = "Connecting to \(host):\(self.manualPort)…"
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
+ }
+
+ private func retryLastAttempt() async {
+ self.connectingGatewayID = "retry"
+ self.issue = .none
+ self.connectMessage = "Retrying…"
+ self.statusLine = "Retrying last connection…"
+ defer { self.connectingGatewayID = nil }
+ await self.gatewayController.connectLastKnown()
+ }
+}
+
+private struct OnboardingModeRow: View {
+ let title: String
+ let subtitle: String
+ let selected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: self.action) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(self.title)
+ .font(.body.weight(.semibold))
+ Text(self.subtitle)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift
new file mode 100644
index 00000000000..d326c09c42b
--- /dev/null
+++ b/apps/ios/Sources/Onboarding/QRScannerView.swift
@@ -0,0 +1,96 @@
+import OpenClawKit
+import SwiftUI
+import VisionKit
+
+struct QRScannerView: UIViewControllerRepresentable {
+ let onGatewayLink: (GatewayConnectDeepLink) -> Void
+ let onError: (String) -> Void
+ let onDismiss: () -> Void
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ guard DataScannerViewController.isSupported else {
+ context.coordinator.reportError("QR scanning is not supported on this device.")
+ return UIViewController()
+ }
+ guard DataScannerViewController.isAvailable else {
+ context.coordinator.reportError("Camera scanning is currently unavailable.")
+ return UIViewController()
+ }
+ let scanner = DataScannerViewController(
+ recognizedDataTypes: [.barcode(symbologies: [.qr])],
+ isHighlightingEnabled: true)
+ scanner.delegate = context.coordinator
+ do {
+ try scanner.startScanning()
+ } catch {
+ context.coordinator.reportError("Could not start QR scanner.")
+ }
+ return scanner
+ }
+
+ func updateUIViewController(_: UIViewController, context _: Context) {}
+
+ static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
+ if let scanner = uiViewController as? DataScannerViewController {
+ scanner.stopScanning()
+ }
+ coordinator.parent.onDismiss()
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ final class Coordinator: NSObject, DataScannerViewControllerDelegate {
+ let parent: QRScannerView
+ private var handled = false
+ private var reportedError = false
+
+ init(parent: QRScannerView) {
+ self.parent = parent
+ }
+
+ func reportError(_ message: String) {
+ guard !self.reportedError else { return }
+ self.reportedError = true
+ Task { @MainActor in
+ self.parent.onError(message)
+ }
+ }
+
+ func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
+ guard !self.handled else { return }
+ for item in items {
+ guard case let .barcode(barcode) = item,
+ let payload = barcode.payloadStringValue
+ else { continue }
+
+ // Try setup code format first (base64url JSON from /pair qr).
+ if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
+ self.handled = true
+ self.parent.onGatewayLink(link)
+ return
+ }
+
+ // Fall back to deep link URL format (openclaw://gateway?...).
+ if let url = URL(string: payload),
+ let route = DeepLinkParser.parse(url),
+ case let .gateway(link) = route
+ {
+ self.handled = true
+ self.parent.onGatewayLink(link)
+ return
+ }
+ }
+ }
+
+ func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
+
+ func dataScanner(
+ _: DataScannerViewController,
+ becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
+ {
+ self.reportError("Camera is not available on this device.")
+ }
+ }
+}
diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift
index 8ad23ae20a1..d180e1fc4d9 100644
--- a/apps/ios/Sources/OpenClawApp.swift
+++ b/apps/ios/Sources/OpenClawApp.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import Foundation
@main
struct OpenClawApp: App {
@@ -7,6 +8,7 @@ struct OpenClawApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
+ Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
@@ -29,3 +31,18 @@ struct OpenClawApp: App {
}
}
}
+
+extension OpenClawApp {
+ private static func installUncaughtExceptionLogger() {
+ NSLog("OpenClaw: installing uncaught exception handler")
+ NSSetUncaughtExceptionHandler { exception in
+ // Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
+ // produce a normal Swift error backtrace.
+ let reason = exception.reason ?? "(no reason)"
+ NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
+ for line in exception.callStackSymbols {
+ NSLog(" %@", line)
+ }
+ }
+ }
+}
diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift
index 36eea522178..249f439fb17 100644
--- a/apps/ios/Sources/Reminders/RemindersService.swift
+++ b/apps/ios/Sources/Reminders/RemindersService.swift
@@ -6,7 +6,7 @@ final class RemindersService: RemindersServicing {
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
- let authorized = await Self.ensureAuthorization(store: store, status: status)
+ let authorized = EventKitAuthorization.allowsRead(status: status)
guard authorized else {
throw NSError(domain: "Reminders", code: 1, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
@@ -50,7 +50,7 @@ final class RemindersService: RemindersServicing {
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
- let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
+ let authorized = EventKitAuthorization.allowsWrite(status: status)
guard authorized else {
throw NSError(domain: "Reminders", code: 2, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
@@ -100,38 +100,6 @@ final class RemindersService: RemindersServicing {
return OpenClawRemindersAddPayload(reminder: payload)
}
- private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
- switch status {
- case .authorized:
- return true
- case .notDetermined:
- // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
- return false
- case .restricted, .denied:
- return false
- case .fullAccess:
- return true
- case .writeOnly:
- return false
- @unknown default:
- return false
- }
- }
-
- private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
- switch status {
- case .authorized, .fullAccess, .writeOnly:
- return true
- case .notDetermined:
- // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
- return false
- case .restricted, .denied:
- return false
- @unknown default:
- return false
- }
- }
-
private static func resolveList(
store: EKEventStore,
listId: String?,
diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift
index d3da84cae8b..a227b3fe336 100644
--- a/apps/ios/Sources/RootCanvas.swift
+++ b/apps/ios/Sources/RootCanvas.swift
@@ -3,34 +3,69 @@ import UIKit
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
+ @Environment(GatewayConnectionController.self) private var gatewayController
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
+ @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
+ @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task?
+ @State private var showOnboarding: Bool = false
+ @State private var onboardingAllowSkip: Bool = true
+ @State private var didEvaluateOnboarding: Bool = false
@State private var didAutoOpenSettings: Bool = false
private enum PresentedSheet: Identifiable {
case settings
case chat
+ case quickSetup
var id: Int {
switch self {
case .settings: 0
case .chat: 1
+ case .quickSetup: 2
}
}
}
+ enum StartupPresentationRoute: Equatable {
+ case none
+ case onboarding
+ case settings
+ }
+
+ static func startupPresentationRoute(
+ gatewayConnected: Bool,
+ hasConnectedOnce: Bool,
+ onboardingComplete: Bool,
+ hasExistingGatewayConfig: Bool,
+ shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
+ {
+ if gatewayConnected {
+ return .none
+ }
+ // On first run or explicit launch onboarding state, onboarding always wins.
+ if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
+ return .onboarding
+ }
+ // Settings auto-open is a recovery path for previously-connected installs only.
+ if !hasExistingGatewayConfig {
+ return .settings
+ }
+ return .none
+ }
+
var body: some View {
ZStack {
CanvasContent(
@@ -52,31 +87,63 @@ struct RootCanvas: View {
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
}
+ .gatewayTrustPromptAlert()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:
SettingsTab()
+ .environment(self.appModel)
+ .environment(self.appModel.voiceWake)
+ .environment(self.gatewayController)
case .chat:
ChatSheet(
- gateway: self.appModel.operatorSession,
+ // Mobile chat UI should use the node role RPC surface (chat.* / sessions.*)
+ // to avoid requiring operator scopes like operator.read.
+ gateway: self.appModel.gatewaySession,
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
+ case .quickSetup:
+ GatewayQuickSetupSheet()
+ .environment(self.appModel)
+ .environment(self.gatewayController)
}
}
+ .fullScreenCover(isPresented: self.$showOnboarding) {
+ OnboardingWizardView(
+ allowSkip: self.onboardingAllowSkip,
+ onClose: {
+ self.showOnboarding = false
+ })
+ .environment(self.appModel)
+ .environment(self.appModel.voiceWake)
+ .environment(self.gatewayController)
+ }
.onAppear { self.updateIdleTimer() }
+ .onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
+ .onAppear { self.maybeShowQuickSetup() }
+ .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
+ .onChange(of: self.appModel.gatewayServerName) { _, newValue in
+ if newValue != nil {
+ self.showOnboarding = false
+ }
+ }
+ .onChange(of: self.onboardingRequestID) { _, _ in
+ self.evaluateOnboardingPresentation(force: true)
+ }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
self.hasConnectedOnce = true
+ OnboardingStateStore.markCompleted(mode: nil)
}
self.maybeAutoOpenSettings()
}
@@ -135,11 +202,31 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
- private func shouldAutoOpenSettings() -> Bool {
- if self.appModel.gatewayServerName != nil { return false }
- if !self.hasConnectedOnce { return true }
- if !self.onboardingComplete { return true }
- return !self.hasExistingGatewayConfig()
+ private func evaluateOnboardingPresentation(force: Bool) {
+ if force {
+ self.onboardingAllowSkip = true
+ self.showOnboarding = true
+ return
+ }
+
+ guard !self.didEvaluateOnboarding else { return }
+ self.didEvaluateOnboarding = true
+ let route = Self.startupPresentationRoute(
+ gatewayConnected: self.appModel.gatewayServerName != nil,
+ hasConnectedOnce: self.hasConnectedOnce,
+ onboardingComplete: self.onboardingComplete,
+ hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
+ shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
+ switch route {
+ case .none:
+ break
+ case .onboarding:
+ self.onboardingAllowSkip = true
+ self.showOnboarding = true
+ case .settings:
+ self.didAutoOpenSettings = true
+ self.presentedSheet = .settings
+ }
}
private func hasExistingGatewayConfig() -> Bool {
@@ -150,10 +237,26 @@ struct RootCanvas: View {
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
- guard self.shouldAutoOpenSettings() else { return }
+ guard !self.showOnboarding else { return }
+ let route = Self.startupPresentationRoute(
+ gatewayConnected: self.appModel.gatewayServerName != nil,
+ hasConnectedOnce: self.hasConnectedOnce,
+ onboardingComplete: self.onboardingComplete,
+ hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
+ shouldPresentOnLaunch: false)
+ guard route == .settings else { return }
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
+
+ private func maybeShowQuickSetup() {
+ guard !self.quickSetupDismissed else { return }
+ guard !self.showOnboarding else { return }
+ guard self.presentedSheet == nil else { return }
+ guard self.appModel.gatewayServerName == nil else { return }
+ guard !self.gatewayController.gateways.isEmpty else { return }
+ self.presentedSheet = .quickSetup
+ }
}
private struct CanvasContent: View {
diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift
index 278e56d6150..4733a4a30fc 100644
--- a/apps/ios/Sources/RootTabs.swift
+++ b/apps/ios/Sources/RootTabs.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct RootTabs: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?
@@ -52,14 +53,14 @@ struct RootTabs: View {
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
- withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
+ withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
- withAnimation(.easeOut(duration: 0.25)) {
+ withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
@@ -104,66 +105,10 @@ struct RootTabs: View {
}
private var statusActivity: StatusPill.Activity? {
- // Keep the top pill consistent across tabs (camera + voice wake + pairing states).
- if self.appModel.isBackgrounded {
- return StatusPill.Activity(
- title: "Foreground required",
- systemImage: "exclamationmark.triangle.fill",
- tint: .orange)
- }
-
- let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
- let gatewayLower = gatewayStatus.lowercased()
- if gatewayLower.contains("repair") {
- return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
- }
- if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
- return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
- }
- // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
-
- if self.appModel.screenRecordActive {
- return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
- }
-
- if let cameraHUDText = self.appModel.cameraHUDText,
- let cameraHUDKind = self.appModel.cameraHUDKind,
- !cameraHUDText.isEmpty
- {
- let systemImage: String
- let tint: Color?
- switch cameraHUDKind {
- case .photo:
- systemImage = "camera.fill"
- tint = nil
- case .recording:
- systemImage = "video.fill"
- tint = .red
- case .success:
- systemImage = "checkmark.circle.fill"
- tint = .green
- case .error:
- systemImage = "exclamationmark.triangle.fill"
- tint = .red
- }
- return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
- }
-
- if self.voiceWakeEnabled {
- let voiceStatus = self.appModel.voiceWake.statusText
- if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
- return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
- }
- if voiceStatus == "Paused" {
- // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
- if self.appModel.talkMode.isEnabled {
- return nil
- }
- let suffix = self.appModel.isBackgrounded ? " (background)" : ""
- return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
- }
- }
-
- return nil
+ StatusActivityBuilder.build(
+ appModel: self.appModel,
+ voiceWakeEnabled: self.voiceWakeEnabled,
+ cameraHUDText: self.appModel.cameraHUDText,
+ cameraHUDKind: self.appModel.cameraHUDKind)
}
}
diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift
index 002c87ad9ca..5ed6f8cfd88 100644
--- a/apps/ios/Sources/Services/NodeServiceProtocols.swift
+++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift
@@ -28,6 +28,12 @@ protocol LocationServicing: Sendable {
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
+ func startLocationUpdates(
+ desiredAccuracy: OpenClawLocationAccuracy,
+ significantChangesOnly: Bool) -> AsyncStream
+ func stopLocationUpdates()
+ func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
+ func stopMonitoringSignificantLocationChanges()
}
protocol DeviceStatusServicing: Sendable {
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index 6267f621c50..915c332554f 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -15,6 +15,8 @@ struct SettingsTab: View {
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@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("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@@ -28,17 +30,27 @@ struct SettingsTab: View {
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
+
+ // Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
+ @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
+ @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
+ @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
+
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
+ @State private var talkElevenLabsApiKey: String = ""
@AppStorage("gateway.setupCode") private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
+ @State private var showResetOnboardingAlert: Bool = false
+ @State private var suppressCredentialPersist: Bool = false
+
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
@@ -103,7 +115,6 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
- DisclosureGroup("Advanced") {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
@@ -148,69 +159,74 @@ struct SettingsTab: View {
self.gatewayList(showing: .all)
}
- Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
+ DisclosureGroup("Advanced") {
+ Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
- TextField("Host", text: self.$manualGatewayHost)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
+ TextField("Host", text: self.$manualGatewayHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
- TextField("Port (optional)", text: self.manualPortBinding)
- .keyboardType(.numberPad)
+ TextField("Port (optional)", text: self.manualPortBinding)
+ .keyboardType(.numberPad)
- Toggle("Use TLS", isOn: self.$manualGatewayTLS)
+ Toggle("Use TLS", isOn: self.$manualGatewayTLS)
- Button {
- Task { await self.connectManual() }
- } label: {
- if self.connectingGatewayID == "manual" {
- HStack(spacing: 8) {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting…")
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect (Manual)")
}
- } else {
- Text("Connect (Manual)")
}
- }
- .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .isEmpty || !self.manualPortIsValid)
+ .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty || !self.manualPortIsValid)
- Text(
- "Use this when mDNS/Bonjour discovery is blocked. "
- + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ Text(
+ "Use this when mDNS/Bonjour discovery is blocked. "
+ + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
- Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
- .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
- self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
+ Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
+ .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
+ self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
+ }
+
+ NavigationLink("Discovery Logs") {
+ GatewayDiscoveryDebugLogView()
}
- NavigationLink("Discovery Logs") {
- GatewayDiscoveryDebugLogView()
+ Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
+
+ TextField("Gateway Auth Token", text: self.$gatewayToken)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ SecureField("Gateway Password", text: self.$gatewayPassword)
+
+ Button("Reset Onboarding", role: .destructive) {
+ self.showResetOnboardingAlert = true
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Debug")
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(self.gatewayDebugText())
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
}
-
- Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
-
- TextField("Gateway Token", text: self.$gatewayToken)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
-
- SecureField("Gateway Password", text: self.$gatewayPassword)
-
- VStack(alignment: .leading, spacing: 6) {
- Text("Debug")
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
- Text(self.gatewayDebugText())
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(10)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
- }
- }
} label: {
HStack(spacing: 10) {
Circle()
@@ -235,6 +251,20 @@ struct SettingsTab: View {
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
+ SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Toggle("Background Listening", isOn: self.$talkBackgroundEnabled)
+ Text("Keep listening when the app is in the background. Uses more battery.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled)
+ Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
@@ -303,8 +333,17 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
+ .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
+ Button("Reset", role: .destructive) {
+ self.resetOnboarding()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text(
+ "This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
+ }
.onAppear {
- self.localIPAddress = Self.primaryIPv4Address()
+ self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw
self.syncManualPortText()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -312,6 +351,7 @@ struct SettingsTab: View {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
+ self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
@@ -331,17 +371,22 @@ struct SettingsTab: View {
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
.onChange(of: self.gatewayToken) { _, newValue in
+ guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
}
.onChange(of: self.gatewayPassword) { _, newValue in
+ guard !self.suppressCredentialPersist else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
+ .onChange(of: self.talkElevenLabsApiKey) { _, newValue in
+ GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
+ }
.onChange(of: self.manualGatewayPort) { _, _ in
self.syncManualPortText()
}
@@ -376,6 +421,7 @@ struct SettingsTab: View {
}
}
}
+ .gatewayTrustPromptAlert()
}
@ViewBuilder
@@ -388,11 +434,13 @@ struct SettingsTab: View {
.font(.footnote)
.foregroundStyle(.secondary)
- if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
+ if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
+ case let .manual(host, port, _, _) = lastKnown
+ {
Button {
Task { await self.connectLastKnown() }
} label: {
- self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
+ self.lastKnownButtonLabel(host: host, port: port)
}
.disabled(self.connectingGatewayID != nil)
.buttonStyle(.borderedProminent)
@@ -418,10 +466,11 @@ struct SettingsTab: View {
ForEach(rows) { gateway in
HStack {
VStack(alignment: .leading, spacing: 2) {
- Text(gateway.name)
+ // Avoid localized-string formatting edge cases from Bonjour-advertised names.
+ Text(verbatim: gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
- Text(line)
+ Text(verbatim: line)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -507,7 +556,10 @@ struct SettingsTab: View {
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
- await self.gatewayController.connect(gateway)
+ let err = await self.gatewayController.connectWithDiagnostics(gateway)
+ if let err {
+ self.setupStatusText = err
+ }
}
private func connectLastKnown() async {
@@ -587,15 +639,6 @@ struct SettingsTab: View {
}
}
- private struct SetupPayload: Codable {
- var url: String?
- var host: String?
- var port: Int?
- var tls: Bool?
- var token: String?
- var password: String?
- }
-
private func applySetupCodeAndConnect() async {
self.setupStatusText = nil
guard self.applySetupCode() else { return }
@@ -623,7 +666,7 @@ struct SettingsTab: View {
return false
}
- guard let payload = self.decodeSetupPayload(raw: raw) else {
+ guard let payload = GatewaySetupCode.decode(raw: raw) else {
self.setupStatusText = "Setup code not recognized."
return false
}
@@ -724,67 +767,14 @@ struct SettingsTab: View {
}
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
- guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
- let endpointHost = NWEndpoint.Host(host)
- let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
- return await withCheckedContinuation { cont in
- let queue = DispatchQueue(label: "gateway.preflight")
- let finished = OSAllocatedUnfairLock(initialState: false)
- let finish: @Sendable (Bool) -> Void = { ok in
- let shouldResume = finished.withLock { flag -> Bool in
- if flag { return false }
- flag = true
- return true
- }
- guard shouldResume else { return }
- connection.cancel()
- cont.resume(returning: ok)
- }
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- finish(true)
- case .failed, .cancelled:
- finish(false)
- default:
- break
- }
- }
- connection.start(queue: queue)
- queue.asyncAfter(deadline: .now() + timeoutSeconds) {
- finish(false)
- }
- }
+ await TCPProbe.probe(
+ host: host,
+ port: port,
+ timeoutSeconds: timeoutSeconds,
+ queueLabel: "gateway.preflight")
}
- private func decodeSetupPayload(raw: String) -> SetupPayload? {
- if let payload = decodeSetupPayloadFromJSON(raw) {
- return payload
- }
- if let decoded = decodeBase64Payload(raw),
- let payload = decodeSetupPayloadFromJSON(decoded)
- {
- return payload
- }
- return nil
- }
-
- private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
- guard let data = json.data(using: .utf8) else { return nil }
- return try? JSONDecoder().decode(SetupPayload.self, from: data)
- }
-
- private func decodeBase64Payload(_ raw: String) -> String? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- let normalized = trimmed
- .replacingOccurrences(of: "-", with: "+")
- .replacingOccurrences(of: "_", with: "/")
- let padding = normalized.count % 4
- let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
- guard let data = Data(base64Encoded: padded) else { return nil }
- return String(data: data, encoding: .utf8)
- }
+ // (GatewaySetupCode) decode raw setup codes.
private func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -849,44 +839,6 @@ struct SettingsTab: View {
return nil
}
- private static func primaryIPv4Address() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- var fallback: String?
- var en0: String?
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let name = String(cString: ptr.pointee.ifa_name)
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
-
- if name == "en0" { en0 = ip; break }
- if fallback == nil { fallback = ip }
- }
-
- return en0 ?? fallback
- }
-
private static func hasTailnetIPv4() -> Bool {
var addrList: UnsafeMutablePointer?
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
@@ -946,6 +898,43 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
+ private func resetOnboarding() {
+ // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
+ self.appModel.disconnectGateway()
+ self.connectingGatewayID = nil
+ self.setupStatusText = nil
+ self.setupCode = ""
+ self.gatewayAutoConnect = false
+
+ self.suppressCredentialPersist = true
+ defer { self.suppressCredentialPersist = false }
+
+ self.gatewayToken = ""
+ self.gatewayPassword = ""
+
+ let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedInstanceId.isEmpty {
+ GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
+ }
+
+ // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
+ GatewaySettingsStore.clearLastGatewayConnection()
+
+ // RootCanvas also short-circuits onboarding when these are true.
+ self.onboardingComplete = false
+ self.hasConnectedOnce = false
+
+ // Clear manual override so it doesn't count as an existing gateway config.
+ self.manualGatewayEnabled = false
+ self.manualGatewayHost = ""
+
+ // Force re-present even without app restart.
+ self.onboardingRequestID += 1
+
+ // The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
+ self.dismiss()
+ }
+
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift
new file mode 100644
index 00000000000..381b3d2b9e8
--- /dev/null
+++ b/apps/ios/Sources/Status/StatusActivityBuilder.swift
@@ -0,0 +1,71 @@
+import SwiftUI
+
+enum StatusActivityBuilder {
+ @MainActor
+ static func build(
+ appModel: NodeAppModel,
+ voiceWakeEnabled: Bool,
+ cameraHUDText: String?,
+ cameraHUDKind: NodeAppModel.CameraHUDKind?
+ ) -> StatusPill.Activity? {
+ // Keep the top pill consistent across tabs (camera + voice wake + pairing states).
+ if appModel.isBackgrounded {
+ return StatusPill.Activity(
+ title: "Foreground required",
+ systemImage: "exclamationmark.triangle.fill",
+ tint: .orange)
+ }
+
+ let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
+ let gatewayLower = gatewayStatus.lowercased()
+ if gatewayLower.contains("repair") {
+ return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
+ }
+ if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
+ return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
+ }
+ // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
+
+ if appModel.screenRecordActive {
+ return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
+ }
+
+ if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
+ let systemImage: String
+ let tint: Color?
+ switch cameraHUDKind {
+ case .photo:
+ systemImage = "camera.fill"
+ tint = nil
+ case .recording:
+ systemImage = "video.fill"
+ tint = .red
+ case .success:
+ systemImage = "checkmark.circle.fill"
+ tint = .green
+ case .error:
+ systemImage = "exclamationmark.triangle.fill"
+ tint = .red
+ }
+ return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
+ }
+
+ if voiceWakeEnabled {
+ let voiceStatus = appModel.voiceWake.statusText
+ if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
+ return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
+ }
+ if voiceStatus == "Paused" {
+ // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
+ if appModel.talkMode.isEnabled {
+ return nil
+ }
+ let suffix = appModel.isBackgrounded ? " (background)" : ""
+ return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
+ }
+ }
+
+ return nil
+ }
+}
+
diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift
index cd81c011bb1..ea5e425c49d 100644
--- a/apps/ios/Sources/Status/StatusPill.swift
+++ b/apps/ios/Sources/Status/StatusPill.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.colorSchemeContrast) private var contrast
enum GatewayState: Equatable {
case connected
@@ -49,11 +51,11 @@ struct StatusPill: View {
Circle()
.fill(self.gateway.color)
.frame(width: 9, height: 9)
- .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
- .opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
+ .scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
+ .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
@@ -64,17 +66,17 @@ struct StatusPill: View {
if let activity {
HStack(spacing: 6) {
Image(systemName: activity.systemImage)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
Text(activity.title)
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
- .font(.system(size: 13, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
.transition(.opacity.combined(with: .move(edge: .top)))
@@ -87,21 +89,28 @@ struct StatusPill: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
+ .strokeBorder(
+ .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
+ lineWidth: self.contrast == .increased ? 1.0 : 0.5
+ )
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
- .accessibilityLabel("Status")
+ .accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
- .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
+ .accessibilityHint("Double tap to open settings")
+ .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
- self.updatePulse(for: newValue, scenePhase: self.scenePhase)
+ self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
- self.updatePulse(for: self.gateway, scenePhase: newValue)
+ self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
+ }
+ .onChange(of: self.reduceMotion) { _, newValue in
+ self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
@@ -113,9 +122,9 @@ struct StatusPill: View {
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
- private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
- guard gateway == .connecting, scenePhase == .active else {
- withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
+ private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
+ guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
+ withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift
index b7942f2036f..ef6fc1295a7 100644
--- a/apps/ios/Sources/Status/VoiceWakeToast.swift
+++ b/apps/ios/Sources/Status/VoiceWakeToast.swift
@@ -1,17 +1,19 @@
import SwiftUI
struct VoiceWakeToast: View {
+ @Environment(\.colorSchemeContrast) private var contrast
+
var command: String
var brighten: Bool = false
var body: some View {
HStack(spacing: 10) {
Image(systemName: "mic.fill")
- .font(.system(size: 14, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.command)
- .font(.system(size: 14, weight: .semibold))
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
@@ -23,11 +25,14 @@ struct VoiceWakeToast: View {
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
+ .strokeBorder(
+ .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
+ lineWidth: self.contrast == .increased ? 1.0 : 0.5
+ )
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
- .accessibilityLabel("Voice Wake")
- .accessibilityValue(self.command)
+ .accessibilityLabel("Voice Wake triggered")
+ .accessibilityValue("Command: \(self.command)")
}
}
diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift
index 8351a6d5f9a..be90208af47 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 redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
var isSpeaking: Bool = false
@@ -218,8 +219,12 @@ final class TalkModeManager: NSObject {
/// Suspends microphone usage without disabling Talk Mode.
/// Used when the app backgrounds (or when we need to temporarily release the mic).
- func suspendForBackground() -> Bool {
+ func suspendForBackground(keepActive: Bool = false) -> Bool {
guard self.isEnabled else { return false }
+ if keepActive {
+ self.statusText = self.isListening ? "Listening" : self.statusText
+ return false
+ }
let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive
self.isListening = false
@@ -246,7 +251,8 @@ final class TalkModeManager: NSObject {
return wasActive
}
- func resumeAfterBackground(wasSuspended: Bool) async {
+ func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
+ if wasKeptActive { return }
guard wasSuspended else { return }
guard self.isEnabled else { return }
await self.start()
@@ -814,29 +820,24 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
- guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
- let payload = "{\"sessionKey\":\"\(key)\"}"
- await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
+ // Operator clients receive chat events without node-style subscriptions.
self.chatSubscribedSessionKeys.insert(key)
- self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
- guard let gateway else { return }
- let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
- for key in keys {
- let payload = "{\"sessionKey\":\"\(key)\"}"
- await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
- }
}
private func buildPrompt(transcript: String) -> String {
let interrupted = self.lastInterruptedAtSeconds
self.lastInterruptedAtSeconds = nil
- return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted)
+ let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
+ return TalkPromptBuilder.build(
+ transcript: transcript,
+ interruptedAtSeconds: interrupted,
+ includeVoiceDirectiveHint: includeVoiceDirectiveHint)
}
private enum ChatCompletionState: CustomStringConvertible {
@@ -1114,6 +1115,7 @@ final class TalkModeManager: NSObject {
}
private func shouldInterrupt(with transcript: String) -> Bool {
+ guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false }
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 3 else { return false }
if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) {
@@ -1122,6 +1124,20 @@ final class TalkModeManager: NSObject {
return true
}
+ private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool {
+ let route = AVAudioSession.sharedInstance().currentRoute
+ // Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts.
+ // Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay).
+ return !route.outputs.contains { output in
+ switch output.portType {
+ case .builtInSpeaker, .builtInReceiver:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+
private func shouldUseIncrementalTTS() -> Bool {
true
}
@@ -1668,6 +1684,15 @@ extension TalkModeManager {
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
}
+ private static func normalizedTalkApiKey(_ raw: String?) -> String? {
+ let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ guard trimmed != Self.redactedConfigSentinel else { return nil }
+ // Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`).
+ if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil }
+ return trimmed
+ }
+
func reloadConfig() async {
guard let gateway else { return }
do {
@@ -1699,7 +1724,15 @@ extension TalkModeManager {
}
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
- self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
+ let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
+ 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 let interrupt = talk?["interruptOnSpeech"] as? Bool {
self.interruptOnSpeech = interrupt
}
diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift
index 9a3d8618738..ea8b2a81203 100644
--- a/apps/ios/Tests/DeepLinkParserTests.swift
+++ b/apps/ios/Tests/DeepLinkParserTests.swift
@@ -76,4 +76,52 @@ import Testing
timeoutSeconds: nil,
key: nil)))
}
+
+ @Test func parseGatewayLinkParsesCommonFields() {
+ let url = URL(
+ string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
+ #expect(
+ DeepLinkParser.parse(url) == .gateway(
+ .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
+ }
+
+ @Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
+ let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
+ let encoded = Data(payload.utf8)
+ .base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+
+ let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+
+ #expect(link == .init(
+ host: "gateway.example.com",
+ port: 443,
+ tls: true,
+ token: "tok",
+ password: "pw"))
+ }
+
+ @Test func parseGatewaySetupCodeRejectsInvalidInput() {
+ #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
+ }
+
+ @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
+ let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
+ let encoded = Data(payload.utf8)
+ .base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+
+ let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+
+ #expect(link == .init(
+ host: "gateway.example.com",
+ port: 443,
+ tls: true,
+ token: "tok",
+ password: nil))
+ }
}
diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift
index 0d3bdbba0ee..27e7aed7aea 100644
--- a/apps/ios/Tests/GatewayConnectionControllerTests.swift
+++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift
@@ -76,4 +76,47 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
}
}
+ @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
+ withUserDefaults([
+ "node.instanceId": "ios-test",
+ "camera.enabled": true,
+ "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
+ ]) {
+ let appModel = NodeAppModel()
+ let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ let commands = Set(controller._test_currentCommands())
+
+ // iOS should expose notify, but not host shell/exec-approval commands.
+ #expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
+ #expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
+ #expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
+ #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
+ #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
+ }
+ }
+
+ @Test @MainActor func loadLastConnectionReadsSavedValues() {
+ withUserDefaults([:]) {
+ GatewaySettingsStore.saveLastGatewayConnectionManual(
+ host: "gateway.example.com",
+ port: 443,
+ useTLS: true,
+ stableID: "manual|gateway.example.com|443")
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
+ }
+ }
+
+ @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
+ withUserDefaults([
+ "gateway.last.kind": "manual",
+ "gateway.last.host": "",
+ "gateway.last.port": 0,
+ "gateway.last.tls": false,
+ "gateway.last.stableID": "manual|invalid|0",
+ ]) {
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == nil)
+ }
+ }
}
diff --git a/apps/ios/Tests/GatewayConnectionIssueTests.swift b/apps/ios/Tests/GatewayConnectionIssueTests.swift
new file mode 100644
index 00000000000..8eb63f268ba
--- /dev/null
+++ b/apps/ios/Tests/GatewayConnectionIssueTests.swift
@@ -0,0 +1,33 @@
+import Testing
+@testable import OpenClaw
+
+@Suite(.serialized) struct GatewayConnectionIssueTests {
+ @Test func detectsTokenMissing() {
+ let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
+ #expect(issue == .tokenMissing)
+ #expect(issue.needsAuthToken)
+ }
+
+ @Test func detectsUnauthorized() {
+ let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
+ #expect(issue == .unauthorized)
+ #expect(issue.needsAuthToken)
+ }
+
+ @Test func detectsPairingWithRequestId() {
+ let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
+ #expect(issue == .pairingRequired(requestId: "abc123"))
+ #expect(issue.needsPairing)
+ #expect(issue.requestId == "abc123")
+ }
+
+ @Test func detectsNetworkError() {
+ let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
+ #expect(issue == .network)
+ }
+
+ @Test func returnsNoneForBenignStatus() {
+ let issue = GatewayConnectionIssue.detect(from: "Connected")
+ #expect(issue == .none)
+ }
+}
diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift
new file mode 100644
index 00000000000..066ccb1dd22
--- /dev/null
+++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift
@@ -0,0 +1,105 @@
+import Foundation
+import Network
+import Testing
+@testable import OpenClaw
+
+@Suite(.serialized) struct GatewayConnectionSecurityTests {
+ private func clearTLSFingerprint(stableID: String) {
+ let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
+ suite.removeObject(forKey: "gateway.tls.\(stableID)")
+ }
+
+ @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
+ let stableID = "test|\(UUID().uuidString)"
+ defer { clearTLSFingerprint(stableID: stableID) }
+ clearTLSFingerprint(stableID: stableID)
+
+ GatewayTLSStore.saveFingerprint("11", stableID: stableID)
+
+ let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
+ let gateway = GatewayDiscoveryModel.DiscoveredGateway(
+ name: "Test",
+ endpoint: endpoint,
+ stableID: stableID,
+ debugID: "debug",
+ lanHost: "evil.example.com",
+ tailnetDns: "evil.example.com",
+ gatewayPort: 12345,
+ canvasPort: nil,
+ tlsEnabled: true,
+ tlsFingerprintSha256: "22",
+ cliPath: nil)
+
+ let appModel = NodeAppModel()
+ let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+
+ let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
+ #expect(params?.expectedFingerprint == "11")
+ #expect(params?.allowTOFU == false)
+ }
+
+ @Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
+ let stableID = "test|\(UUID().uuidString)"
+ defer { clearTLSFingerprint(stableID: stableID) }
+ clearTLSFingerprint(stableID: stableID)
+
+ let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
+ let gateway = GatewayDiscoveryModel.DiscoveredGateway(
+ name: "Test",
+ endpoint: endpoint,
+ stableID: stableID,
+ debugID: "debug",
+ lanHost: nil,
+ tailnetDns: nil,
+ gatewayPort: nil,
+ canvasPort: nil,
+ tlsEnabled: true,
+ tlsFingerprintSha256: "22",
+ cliPath: nil)
+
+ let appModel = NodeAppModel()
+ let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+
+ let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
+ #expect(params?.expectedFingerprint == nil)
+ #expect(params?.allowTOFU == false)
+ }
+
+ @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
+ let stableID = "test|\(UUID().uuidString)"
+ defer { clearTLSFingerprint(stableID: stableID) }
+ clearTLSFingerprint(stableID: stableID)
+
+ let defaults = UserDefaults.standard
+ defaults.set(true, forKey: "gateway.autoconnect")
+ defaults.set(false, forKey: "gateway.manual.enabled")
+ defaults.removeObject(forKey: "gateway.last.host")
+ defaults.removeObject(forKey: "gateway.last.port")
+ defaults.removeObject(forKey: "gateway.last.tls")
+ defaults.removeObject(forKey: "gateway.last.stableID")
+ defaults.removeObject(forKey: "gateway.last.kind")
+ defaults.removeObject(forKey: "gateway.preferredStableID")
+ defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
+
+ let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
+ let gateway = GatewayDiscoveryModel.DiscoveredGateway(
+ name: "Test",
+ endpoint: endpoint,
+ stableID: stableID,
+ debugID: "debug",
+ lanHost: "test.local",
+ tailnetDns: nil,
+ gatewayPort: 18789,
+ canvasPort: nil,
+ tlsEnabled: true,
+ tlsFingerprintSha256: nil,
+ cliPath: nil)
+
+ let appModel = NodeAppModel()
+ let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ controller._test_setGateways([gateway])
+ controller._test_triggerAutoConnect()
+
+ #expect(controller._test_didAutoConnect() == false)
+ }
+}
diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift
index cd9842239cd..7e67ab84a97 100644
--- a/apps/ios/Tests/GatewaySettingsStoreTests.swift
+++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift
@@ -124,4 +124,76 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
+
+ @Test func lastGateway_manualRoundTrip() {
+ let keys = [
+ "gateway.last.kind",
+ "gateway.last.host",
+ "gateway.last.port",
+ "gateway.last.tls",
+ "gateway.last.stableID",
+ ]
+ let snapshot = snapshotDefaults(keys)
+ defer { restoreDefaults(snapshot) }
+
+ GatewaySettingsStore.saveLastGatewayConnectionManual(
+ host: "example.com",
+ port: 443,
+ useTLS: true,
+ stableID: "manual|example.com|443")
+
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
+ }
+
+ @Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
+ let keys = [
+ "gateway.last.kind",
+ "gateway.last.host",
+ "gateway.last.port",
+ "gateway.last.tls",
+ "gateway.last.stableID",
+ ]
+ let snapshot = snapshotDefaults(keys)
+ defer { restoreDefaults(snapshot) }
+
+ // Simulate a prior manual record that included host/port.
+ applyDefaults([
+ "gateway.last.host": "10.0.0.99",
+ "gateway.last.port": 18789,
+ "gateway.last.tls": true,
+ "gateway.last.stableID": "manual|10.0.0.99|18789",
+ "gateway.last.kind": "manual",
+ ])
+
+ GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
+
+ let defaults = UserDefaults.standard
+ #expect(defaults.object(forKey: "gateway.last.host") == nil)
+ #expect(defaults.object(forKey: "gateway.last.port") == nil)
+ #expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
+ }
+
+ @Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
+ let keys = [
+ "gateway.last.kind",
+ "gateway.last.host",
+ "gateway.last.port",
+ "gateway.last.tls",
+ "gateway.last.stableID",
+ ]
+ let snapshot = snapshotDefaults(keys)
+ defer { restoreDefaults(snapshot) }
+
+ applyDefaults([
+ "gateway.last.kind": nil,
+ "gateway.last.host": "example.org",
+ "gateway.last.port": 18789,
+ "gateway.last.tls": false,
+ "gateway.last.stableID": "manual|example.org|18789",
+ ])
+
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
+ }
}
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index 3c51da578a5..e738e064fcd 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -15,10 +15,10 @@
CFBundleName
$(PRODUCT_NAME)
CFBundlePackageType
- BNDL
- CFBundleShortVersionString
- 2026.2.13
- CFBundleVersion
- 20260213
-
-
+ BNDL
+ CFBundleShortVersionString
+ 2026.2.16
+ CFBundleVersion
+ 20260216
+
+
diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift
new file mode 100644
index 00000000000..30c014647b6
--- /dev/null
+++ b/apps/ios/Tests/OnboardingStateStoreTests.swift
@@ -0,0 +1,57 @@
+import Foundation
+import Testing
+@testable import OpenClaw
+
+@Suite(.serialized) struct OnboardingStateStoreTests {
+ @Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
+ let testDefaults = self.makeDefaults()
+ let defaults = testDefaults.defaults
+ defer { self.reset(testDefaults) }
+
+ let appModel = NodeAppModel()
+ appModel.gatewayServerName = nil
+ #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
+ }
+
+ @Test @MainActor func doesNotPresentWhenConnected() {
+ let testDefaults = self.makeDefaults()
+ let defaults = testDefaults.defaults
+ defer { self.reset(testDefaults) }
+
+ let appModel = NodeAppModel()
+ appModel.gatewayServerName = "gateway"
+ #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
+ }
+
+ @Test @MainActor func markCompletedPersistsMode() {
+ let testDefaults = self.makeDefaults()
+ let defaults = testDefaults.defaults
+ defer { self.reset(testDefaults) }
+
+ let appModel = NodeAppModel()
+ appModel.gatewayServerName = nil
+
+ OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
+ #expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
+ #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
+
+ OnboardingStateStore.markIncomplete(defaults: defaults)
+ #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
+ }
+
+ private struct TestDefaults {
+ var suiteName: String
+ var defaults: UserDefaults
+ }
+
+ private func makeDefaults() -> TestDefaults {
+ let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
+ return TestDefaults(
+ suiteName: suiteName,
+ defaults: UserDefaults(suiteName: suiteName) ?? .standard)
+ }
+
+ private func reset(_ defaults: TestDefaults) {
+ defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
+ }
+}
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index c4342f8f22b..4231172b777 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
- CFBundleShortVersionString: "2026.2.13"
- CFBundleVersion: "20260213"
+ CFBundleShortVersionString: "2026.2.16"
+ CFBundleVersion: "20260216"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
- CFBundleShortVersionString: "2026.2.13"
- CFBundleVersion: "20260213"
+ CFBundleShortVersionString: "2026.2.16"
+ CFBundleVersion: "20260216"
diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift
index ede898ebac2..b61cfee89a5 100644
--- a/apps/macos/Sources/OpenClaw/AboutSettings.swift
+++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift
@@ -110,8 +110,8 @@ struct AboutSettings: View {
private var buildTimestamp: String? {
guard
let raw =
- (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
- (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
+ (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
+ (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
else { return nil }
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime]
diff --git a/apps/macos/Sources/OpenClaw/AgeFormatting.swift b/apps/macos/Sources/OpenClaw/AgeFormatting.swift
index f992c2d95e3..5bb46bf459d 100644
--- a/apps/macos/Sources/OpenClaw/AgeFormatting.swift
+++ b/apps/macos/Sources/OpenClaw/AgeFormatting.swift
@@ -1,6 +1,6 @@
import Foundation
-// Human-friendly age string (e.g., "2m ago").
+/// Human-friendly age string (e.g., "2m ago").
func age(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
let minutes = seconds / 60
diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift
index 603f837f45e..57164ebb892 100644
--- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift
+++ b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift
@@ -19,7 +19,7 @@ enum AgentWorkspace {
]
enum BootstrapSafety: Equatable {
case safe
- case unsafe(reason: String)
+ case unsafe (reason: String)
}
static func displayPath(for url: URL) -> String {
@@ -72,7 +72,7 @@ enum AgentWorkspace {
return .safe
}
if !isDir.boolValue {
- return .unsafe(reason: "Workspace path points to a file.")
+ return .unsafe (reason: "Workspace path points to a file.")
}
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) {
@@ -82,9 +82,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.")
+ : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
} catch {
- return .unsafe(reason: "Couldn't inspect the workspace folder.")
+ return .unsafe (reason: "Couldn't inspect the workspace folder.")
}
}
diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
index 408b881ba8f..f594cc04c31 100644
--- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
+++ b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
@@ -234,9 +234,8 @@ enum OpenClawOAuthStore {
return URL(fileURLWithPath: expanded, isDirectory: true)
}
let home = FileManager().homeDirectoryForCurrentUser
- let preferred = home.appendingPathComponent(".openclaw", isDirectory: true)
+ return home.appendingPathComponent(".openclaw", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
- return preferred
}
static func oauthURL() -> URL {
diff --git a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift
index acc54a0a14e..3cb8f54e396 100644
--- a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift
+++ b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift
@@ -1,18 +1,34 @@
-import OpenClawKit
-import OpenClawProtocol
import Foundation
+import OpenClawKit
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = OpenClawKit.AnyCodable
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
extension AnyCodable {
- var stringValue: String? { self.value as? String }
- var boolValue: Bool? { self.value as? Bool }
- var intValue: Int? { self.value as? Int }
- var doubleValue: Double? { self.value as? Double }
- var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
- var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
+ var stringValue: String? {
+ self.value as? String
+ }
+
+ var boolValue: Bool? {
+ self.value as? Bool
+ }
+
+ var intValue: Int? {
+ self.value as? Int
+ }
+
+ var doubleValue: Double? {
+ self.value as? Double
+ }
+
+ var dictionaryValue: [String: AnyCodable]? {
+ self.value as? [String: AnyCodable]
+ }
+
+ var arrayValue: [AnyCodable]? {
+ self.value as? [AnyCodable]
+ }
var foundationValue: Any {
switch self.value {
@@ -25,23 +41,3 @@ extension AnyCodable {
}
}
}
-
-extension OpenClawProtocol.AnyCodable {
- var stringValue: String? { self.value as? String }
- var boolValue: Bool? { self.value as? Bool }
- var intValue: Int? { self.value as? Int }
- var doubleValue: Double? { self.value as? Double }
- var dictionaryValue: [String: OpenClawProtocol.AnyCodable]? { self.value as? [String: OpenClawProtocol.AnyCodable] }
- var arrayValue: [OpenClawProtocol.AnyCodable]? { self.value as? [OpenClawProtocol.AnyCodable] }
-
- var foundationValue: Any {
- switch self.value {
- case let dict as [String: OpenClawProtocol.AnyCodable]:
- dict.mapValues { $0.foundationValue }
- case let array as [OpenClawProtocol.AnyCodable]:
- array.map(\.foundationValue)
- default:
- self.value
- }
- }
-}
diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift
index ce2a251cfc9..d960d3c038a 100644
--- a/apps/macos/Sources/OpenClaw/AppState.swift
+++ b/apps/macos/Sources/OpenClaw/AppState.swift
@@ -422,11 +422,10 @@ final class AppState {
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
let port = parsed.port
- let assembled: String
- if let user {
- assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
+ let assembled: String = if let user {
+ port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
} else {
- assembled = port == 22 ? host : "\(host):\(port)"
+ port == 22 ? host : "\(host):\(port)"
}
if assembled != self.remoteTarget {
self.remoteTarget = assembled
@@ -698,7 +697,9 @@ extension AppState {
@MainActor
enum AppStateStore {
static let shared = AppState()
- static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
+ static var isPausedFlag: Bool {
+ UserDefaults.standard.bool(forKey: pauseDefaultsKey)
+ }
static func updateLaunchAtLogin(enabled: Bool) {
Task.detached(priority: .utility) {
diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
index 8653b05dcbb..24717ec5536 100644
--- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
+++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
@@ -1,8 +1,8 @@
import AVFoundation
-import OpenClawIPC
-import OpenClawKit
import CoreGraphics
import Foundation
+import OpenClawIPC
+import OpenClawKit
import OSLog
actor CameraCaptureService {
@@ -106,14 +106,16 @@ actor CameraCaptureService {
}
withExtendedLifetime(delegate) {}
- let maxPayloadBytes = 5 * 1024 * 1024
- // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
- let maxEncodedBytes = (maxPayloadBytes / 4) * 3
- let res = try JPEGTranscoder.transcodeToJPEG(
- imageData: rawData,
- maxWidthPx: maxWidth,
- quality: quality,
- maxBytes: maxEncodedBytes)
+ let res: (data: Data, widthPx: Int, heightPx: Int)
+ do {
+ res = try PhotoCapture.transcodeJPEGForGateway(
+ rawData: rawData,
+ maxWidthPx: maxWidth,
+ quality: quality)
+ } catch {
+ throw CameraError.captureFailed(error.localizedDescription)
+ }
+
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
}
@@ -355,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
- error: Error?)
- {
+ error: Error?
+ ) {
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
@@ -378,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
- error: Error?)
- {
+ error: Error?
+ ) {
guard let error else { return }
guard !self.didResume, let cont else { return }
self.didResume = true
diff --git a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
index 2faca73c18f..40f443c5c8b 100644
--- a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
@@ -1,7 +1,7 @@
import AppKit
+import Foundation
import OpenClawIPC
import OpenClawKit
-import Foundation
import WebKit
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
diff --git a/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift b/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift
index 89c19ef1385..b4158167dcf 100644
--- a/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift
@@ -39,7 +39,9 @@ final class HoverChromeContainerView: NSView {
}
@available(*, unavailable)
- required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
override func updateTrackingAreas() {
super.updateTrackingAreas()
@@ -60,14 +62,18 @@ final class HoverChromeContainerView: NSView {
self.window?.performDrag(with: event)
}
- override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
+ override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
+ true
+ }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
- override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
+ override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
+ true
+ }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
@@ -102,7 +108,9 @@ final class HoverChromeContainerView: NSView {
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private final class PassthroughVisualEffectView: NSVisualEffectView {
- override func hitTest(_: NSPoint) -> NSView? { nil }
+ override func hitTest(_: NSPoint) -> NSView? {
+ nil
+ }
}
private let closeBackground: NSVisualEffectView = {
@@ -190,7 +198,9 @@ final class HoverChromeContainerView: NSView {
}
@available(*, unavailable)
- required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
index 3cf800fd108..3ed0d67ffbc 100644
--- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
@@ -1,17 +1,13 @@
-import CoreServices
import Foundation
final class CanvasFileWatcher: @unchecked Sendable {
- private let url: URL
- private let queue: DispatchQueue
- private var stream: FSEventStreamRef?
- private var pending = false
- private let onChange: () -> Void
+ private let watcher: CoalescingFSEventsWatcher
init(url: URL, onChange: @escaping () -> Void) {
- self.url = url
- self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher")
- self.onChange = onChange
+ self.watcher = CoalescingFSEventsWatcher(
+ paths: [url.path],
+ queueLabel: "ai.openclaw.canvaswatcher",
+ onChange: onChange)
}
deinit {
@@ -19,76 +15,10 @@ final class CanvasFileWatcher: @unchecked Sendable {
}
func start() {
- guard self.stream == nil else { return }
-
- let retainedSelf = Unmanaged.passRetained(self)
- var context = FSEventStreamContext(
- version: 0,
- info: retainedSelf.toOpaque(),
- retain: nil,
- release: { pointer in
- guard let pointer else { return }
- Unmanaged.fromOpaque(pointer).release()
- },
- copyDescription: nil)
-
- let paths = [self.url.path] as CFArray
- let flags = FSEventStreamCreateFlags(
- kFSEventStreamCreateFlagFileEvents |
- kFSEventStreamCreateFlagUseCFTypes |
- kFSEventStreamCreateFlagNoDefer)
-
- guard let stream = FSEventStreamCreate(
- kCFAllocatorDefault,
- Self.callback,
- &context,
- paths,
- FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
- 0.05,
- flags)
- else {
- retainedSelf.release()
- return
- }
-
- self.stream = stream
- FSEventStreamSetDispatchQueue(stream, self.queue)
- if FSEventStreamStart(stream) == false {
- self.stream = nil
- FSEventStreamSetDispatchQueue(stream, nil)
- FSEventStreamInvalidate(stream)
- FSEventStreamRelease(stream)
- }
+ self.watcher.start()
}
func stop() {
- guard let stream = self.stream else { return }
- self.stream = nil
- FSEventStreamStop(stream)
- FSEventStreamSetDispatchQueue(stream, nil)
- FSEventStreamInvalidate(stream)
- FSEventStreamRelease(stream)
- }
-}
-
-extension CanvasFileWatcher {
- private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
- guard let info else { return }
- let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue()
- watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
- }
-
- private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) {
- guard numEvents > 0 else { return }
- guard eventFlags != nil else { return }
-
- // Coalesce rapid changes (common during builds/atomic saves).
- if self.pending { return }
- self.pending = true
- self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
- guard let self else { return }
- self.pending = false
- self.onChange()
- }
+ self.watcher.stop()
}
}
diff --git a/apps/macos/Sources/OpenClaw/CanvasManager.swift b/apps/macos/Sources/OpenClaw/CanvasManager.swift
index 0055ffcfe21..843f78842bd 100644
--- a/apps/macos/Sources/OpenClaw/CanvasManager.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasManager.swift
@@ -1,7 +1,7 @@
import AppKit
+import Foundation
import OpenClawIPC
import OpenClawKit
-import Foundation
import OSLog
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift b/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift
index 3241c08e0d2..6905af50014 100644
--- a/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift
@@ -1,5 +1,5 @@
-import OpenClawKit
import Foundation
+import OpenClawKit
import OSLog
import WebKit
diff --git a/apps/macos/Sources/OpenClaw/CanvasWindow.swift b/apps/macos/Sources/OpenClaw/CanvasWindow.swift
index 0cb3b7c0769..a87f3256170 100644
--- a/apps/macos/Sources/OpenClaw/CanvasWindow.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasWindow.swift
@@ -11,8 +11,13 @@ enum CanvasLayout {
}
final class CanvasPanel: NSPanel {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { true }
+ override var canBecomeKey: Bool {
+ true
+ }
+
+ override var canBecomeMain: Bool {
+ true
+ }
}
enum CanvasPresentation {
diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift
index 7139b6834d4..16e0b01d294 100644
--- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift
@@ -19,7 +19,8 @@ extension CanvasWindowController {
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "openclaw" {
if let currentScheme = self.webView.url?.scheme,
- CanvasScheme.allSchemes.contains(currentScheme) {
+ CanvasScheme.allSchemes.contains(currentScheme)
+ {
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger
diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
index ee15a6abb67..d30f54186ae 100644
--- a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
@@ -1,7 +1,7 @@
import AppKit
+import Foundation
import OpenClawIPC
import OpenClawKit
-import Foundation
import WebKit
@MainActor
@@ -183,7 +183,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
@available(*, unavailable)
- required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
@MainActor deinit {
for name in CanvasA2UIActionMessageHandler.allMessageNames {
diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift
index ea82aac013d..2bef47f2dea 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift
@@ -10,7 +10,6 @@ extension ChannelsSettings {
}
}
- @ViewBuilder
func channelHeaderActions(_ channel: ChannelItem) -> some View {
HStack(spacing: 8) {
if channel.id == "whatsapp" {
@@ -88,7 +87,6 @@ extension ChannelsSettings {
}
}
- @ViewBuilder
func genericChannelSection(_ channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 16) {
self.configEditorSection(channelId: channel.id)
diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift
index c56cb320785..703c7efed63 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
extension ChannelsStore {
func loadConfigSchema() async {
diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift
index 0610fe46438..fd516480f96 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
extension ChannelsStore {
func start() {
diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/apps/macos/Sources/OpenClaw/ChannelsStore.swift
index 724862efd72..09b9b75a532 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsStore.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsStore.swift
@@ -1,6 +1,6 @@
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawProtocol
struct ChannelsStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
diff --git a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
new file mode 100644
index 00000000000..7999123dbe2
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
@@ -0,0 +1,111 @@
+import CoreServices
+import Foundation
+
+final class CoalescingFSEventsWatcher: @unchecked Sendable {
+ private let queue: DispatchQueue
+ private var stream: FSEventStreamRef?
+ private var pending = false
+
+ private let paths: [String]
+ private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool
+ private let onChange: () -> Void
+ private let coalesceDelay: TimeInterval
+
+ init(
+ paths: [String],
+ queueLabel: String,
+ coalesceDelay: TimeInterval = 0.12,
+ shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
+ onChange: @escaping () -> Void
+ ) {
+ self.paths = paths
+ self.queue = DispatchQueue(label: queueLabel)
+ self.coalesceDelay = coalesceDelay
+ self.shouldNotify = shouldNotify
+ self.onChange = onChange
+ }
+
+ deinit {
+ self.stop()
+ }
+
+ func start() {
+ guard self.stream == nil else { return }
+
+ let retainedSelf = Unmanaged.passRetained(self)
+ var context = FSEventStreamContext(
+ version: 0,
+ info: retainedSelf.toOpaque(),
+ retain: nil,
+ release: { pointer in
+ guard let pointer else { return }
+ Unmanaged.fromOpaque(pointer).release()
+ },
+ copyDescription: nil)
+
+ let paths = self.paths as CFArray
+ let flags = FSEventStreamCreateFlags(
+ kFSEventStreamCreateFlagFileEvents |
+ kFSEventStreamCreateFlagUseCFTypes |
+ kFSEventStreamCreateFlagNoDefer)
+
+ guard let stream = FSEventStreamCreate(
+ kCFAllocatorDefault,
+ Self.callback,
+ &context,
+ paths,
+ FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
+ 0.05,
+ flags)
+ else {
+ retainedSelf.release()
+ return
+ }
+
+ self.stream = stream
+ FSEventStreamSetDispatchQueue(stream, self.queue)
+ if FSEventStreamStart(stream) == false {
+ self.stream = nil
+ FSEventStreamSetDispatchQueue(stream, nil)
+ FSEventStreamInvalidate(stream)
+ FSEventStreamRelease(stream)
+ }
+ }
+
+ func stop() {
+ guard let stream = self.stream else { return }
+ self.stream = nil
+ FSEventStreamStop(stream)
+ FSEventStreamSetDispatchQueue(stream, nil)
+ FSEventStreamInvalidate(stream)
+ FSEventStreamRelease(stream)
+ }
+}
+
+extension CoalescingFSEventsWatcher {
+ private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
+ guard let info else { return }
+ let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue()
+ watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
+ }
+
+ private func handleEvents(
+ numEvents: Int,
+ eventPaths: UnsafeMutableRawPointer?,
+ eventFlags: UnsafePointer?
+ ) {
+ guard numEvents > 0 else { return }
+ guard eventFlags != nil else { return }
+ guard self.shouldNotify(numEvents, eventPaths) else { return }
+
+ // Coalesce rapid changes (common during builds/atomic saves).
+ if self.pending { return }
+ self.pending = true
+ self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in
+ guard let self else { return }
+ self.pending = false
+ self.onChange()
+ }
+ }
+}
+
diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
index 23689f1fb9d..4434443497e 100644
--- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
@@ -1,23 +1,34 @@
-import CoreServices
import Foundation
final class ConfigFileWatcher: @unchecked Sendable {
private let url: URL
- private let queue: DispatchQueue
- private var stream: FSEventStreamRef?
- private var pending = false
- private let onChange: () -> Void
private let watchedDir: URL
private let targetPath: String
private let targetName: String
+ private let watcher: CoalescingFSEventsWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
- self.queue = DispatchQueue(label: "ai.openclaw.configwatcher")
- self.onChange = onChange
self.watchedDir = url.deletingLastPathComponent()
self.targetPath = url.path
self.targetName = url.lastPathComponent
+ let watchedDirPath = self.watchedDir.path
+ let targetPath = self.targetPath
+ let targetName = self.targetName
+ self.watcher = CoalescingFSEventsWatcher(
+ paths: [watchedDirPath],
+ queueLabel: "ai.openclaw.configwatcher",
+ shouldNotify: { _, eventPaths in
+ guard let eventPaths else { return true }
+ let paths = unsafeBitCast(eventPaths, to: NSArray.self)
+ for case let path as String in paths {
+ if path == targetPath { return true }
+ if path.hasSuffix("/\(targetName)") { return true }
+ if path == watchedDirPath { return true }
+ }
+ return false
+ },
+ onChange: onChange)
}
deinit {
@@ -25,94 +36,10 @@ final class ConfigFileWatcher: @unchecked Sendable {
}
func start() {
- guard self.stream == nil else { return }
-
- let retainedSelf = Unmanaged.passRetained(self)
- var context = FSEventStreamContext(
- version: 0,
- info: retainedSelf.toOpaque(),
- retain: nil,
- release: { pointer in
- guard let pointer else { return }
- Unmanaged.fromOpaque(pointer).release()
- },
- copyDescription: nil)
-
- let paths = [self.watchedDir.path] as CFArray
- let flags = FSEventStreamCreateFlags(
- kFSEventStreamCreateFlagFileEvents |
- kFSEventStreamCreateFlagUseCFTypes |
- kFSEventStreamCreateFlagNoDefer)
-
- guard let stream = FSEventStreamCreate(
- kCFAllocatorDefault,
- Self.callback,
- &context,
- paths,
- FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
- 0.05,
- flags)
- else {
- retainedSelf.release()
- return
- }
-
- self.stream = stream
- FSEventStreamSetDispatchQueue(stream, self.queue)
- if FSEventStreamStart(stream) == false {
- self.stream = nil
- FSEventStreamSetDispatchQueue(stream, nil)
- FSEventStreamInvalidate(stream)
- FSEventStreamRelease(stream)
- }
+ self.watcher.start()
}
func stop() {
- guard let stream = self.stream else { return }
- self.stream = nil
- FSEventStreamStop(stream)
- FSEventStreamSetDispatchQueue(stream, nil)
- FSEventStreamInvalidate(stream)
- FSEventStreamRelease(stream)
- }
-}
-
-extension ConfigFileWatcher {
- private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
- guard let info else { return }
- let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue()
- watcher.handleEvents(
- numEvents: numEvents,
- eventPaths: eventPaths,
- eventFlags: eventFlags)
- }
-
- private func handleEvents(
- numEvents: Int,
- eventPaths: UnsafeMutableRawPointer?,
- eventFlags: UnsafePointer?)
- {
- guard numEvents > 0 else { return }
- guard eventFlags != nil else { return }
- guard self.matchesTarget(eventPaths: eventPaths) else { return }
-
- if self.pending { return }
- self.pending = true
- self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
- guard let self else { return }
- self.pending = false
- self.onChange()
- }
- }
-
- private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
- guard let eventPaths else { return true }
- let paths = unsafeBitCast(eventPaths, to: NSArray.self)
- for case let path as String in paths {
- if path == self.targetPath { return true }
- if path.hasSuffix("/\(self.targetName)") { return true }
- if path == self.watchedDir.path { return true }
- }
- return false
+ self.watcher.stop()
}
}
diff --git a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift b/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift
index 4a7d4e0a48a..406d908d0b7 100644
--- a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift
@@ -39,11 +39,26 @@ struct ConfigSchemaNode {
self.raw = dict
}
- var title: String? { self.raw["title"] as? String }
- var description: String? { self.raw["description"] as? String }
- var enumValues: [Any]? { self.raw["enum"] as? [Any] }
- var constValue: Any? { self.raw["const"] }
- var explicitDefault: Any? { self.raw["default"] }
+ var title: String? {
+ self.raw["title"] as? String
+ }
+
+ var description: String? {
+ self.raw["description"] as? String
+ }
+
+ var enumValues: [Any]? {
+ self.raw["enum"] as? [Any]
+ }
+
+ var constValue: Any? {
+ self.raw["const"]
+ }
+
+ var explicitDefault: Any? {
+ self.raw["default"]
+ }
+
var requiredKeys: Set {
Set((self.raw["required"] as? [String]) ?? [])
}
diff --git a/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/apps/macos/Sources/OpenClaw/ConfigSettings.swift
index f64a6bce94e..096ae3f7149 100644
--- a/apps/macos/Sources/OpenClaw/ConfigSettings.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigSettings.swift
@@ -45,7 +45,9 @@ extension ConfigSettings {
let help: String?
let node: ConfigSchemaNode
- var id: String { self.key }
+ var id: String {
+ self.key
+ }
}
private struct ConfigSubsection: Identifiable {
@@ -55,7 +57,9 @@ extension ConfigSettings {
let node: ConfigSchemaNode
let path: ConfigPath
- var id: String { self.key }
+ var id: String {
+ self.key
+ }
}
private var sections: [ConfigSection] {
diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift
index 4e9437ff86e..8fd779c6456 100644
--- a/apps/macos/Sources/OpenClaw/ConfigStore.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
enum ConfigStore {
struct Overrides: Sendable {
diff --git a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
index 41005e8260e..f9a11b9e512 100644
--- a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
+++ b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
@@ -70,7 +70,6 @@ struct ContextMenuCardView: View {
return "\(count) sessions · 24h"
}
- @ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift
index 9436b22ecb8..16b4d6d3ad4 100644
--- a/apps/macos/Sources/OpenClaw/ControlChannel.swift
+++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import SwiftUI
struct ControlHeartbeatEvent: Codable {
@@ -15,7 +15,10 @@ struct ControlHeartbeatEvent: Codable {
}
struct ControlAgentEvent: Codable, Sendable, Identifiable {
- var id: String { "\(self.runId)-\(self.seq)" }
+ var id: String {
+ "\(self.runId)-\(self.seq)"
+ }
+
let runId: String
let seq: Int
let stream: String
diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
index 544c9a7c6c8..6b3fc85a7c0 100644
--- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
+++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
import SwiftUI
extension CronJobEditor {
diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift
index 517d32df445..a7d88a4f2fb 100644
--- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift
+++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Observation
+import OpenClawProtocol
import SwiftUI
struct CronJobEditor: View {
@@ -32,18 +32,24 @@ struct CronJobEditor: View {
@State var wakeMode: CronWakeMode = .now
@State var deleteAfterRun: Bool = false
- enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
+ enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String {
+ rawValue
+ } }
@State var scheduleKind: ScheduleKind = .every
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
@State var everyText: String = "1h"
@State var cronExpr: String = "0 9 * * 3"
@State var cronTz: String = ""
- enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
+ enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String {
+ rawValue
+ } }
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
- enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
+ enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String {
+ rawValue
+ } }
@State var deliveryMode: DeliveryChoice = .announce
@State var channel: String = "last"
@State var to: String = ""
@@ -244,7 +250,6 @@ struct CronJobEditor: View {
}
}
}
-
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift
index cb84a2b41fd..21c70ded584 100644
--- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift
+++ b/apps/macos/Sources/OpenClaw/CronJobsStore.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import OSLog
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift
index 4c977c9c128..cbfbc061d6a 100644
--- a/apps/macos/Sources/OpenClaw/CronModels.swift
+++ b/apps/macos/Sources/OpenClaw/CronModels.swift
@@ -4,21 +4,28 @@ enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
case main
case isolated
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
}
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
case now
case nextHeartbeat = "next-heartbeat"
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
}
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
+ case webhook
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
}
struct CronDelivery: Codable, Equatable {
@@ -98,11 +105,11 @@ enum CronSchedule: Codable, Equatable {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date }
- return makeIsoFormatter(withFractional: false).date(from: trimmed)
+ return self.makeIsoFormatter(withFractional: false).date(from: trimmed)
}
static func formatIsoDate(_ date: Date) -> String {
- makeIsoFormatter(withFractional: false).string(from: date)
+ self.makeIsoFormatter(withFractional: false).string(from: date)
}
private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter {
@@ -231,7 +238,9 @@ struct CronEvent: Codable, Sendable {
}
struct CronRunLogEntry: Codable, Identifiable, Sendable {
- var id: String { "\(self.jobId)-\(self.ts)" }
+ var id: String {
+ "\(self.jobId)-\(self.ts)"
+ }
let ts: Int
let jobId: String
@@ -243,7 +252,10 @@ struct CronRunLogEntry: Codable, Identifiable, Sendable {
let durationMs: Int?
let nextRunAtMs: Int?
- var date: Date { Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) }
+ var date: Date {
+ Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000)
+ }
+
var runDate: Date? {
guard let runAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift b/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift
index d5fe92ae010..3fffaf90fd5 100644
--- a/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift
+++ b/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
extension CronSettings {
func save(payload: [String: AnyCodable]) async {
diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift
index 13543e658b3..61b7dcd8ae6 100644
--- a/apps/macos/Sources/OpenClaw/DeepLinks.swift
+++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift
@@ -1,20 +1,57 @@
import AppKit
-import OpenClawKit
import Foundation
+import OpenClawKit
import OSLog
import Security
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
+enum DeepLinkAgentPolicy {
+ static let maxMessageChars = 20000
+ static let maxUnkeyedConfirmChars = 240
+
+ enum ValidationError: Error, Equatable, LocalizedError {
+ case messageTooLongForConfirmation(max: Int, actual: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case let .messageTooLongForConfirmation(max, actual):
+ "Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
+ }
+ }
+ }
+
+ static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result {
+ if !allowUnattended, message.count > self.maxUnkeyedConfirmChars {
+ return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count))
+ }
+ return .success(())
+ }
+
+ static func effectiveDelivery(
+ link: AgentDeepLink,
+ allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel)
+ {
+ if !allowUnattended {
+ // Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk.
+ return (deliver: false, to: nil, channel: .last)
+ }
+ let channel = GatewayAgentChannel(raw: link.channel)
+ let deliver = channel.shouldDeliver(link.deliver)
+ let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
+ return (deliver: deliver, to: to, channel: channel)
+ }
+}
+
@MainActor
final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
- // Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
- // This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
- // outside callers can't know this randomly generated key.
+ /// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
+ /// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
+ /// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
func handle(url: URL) async {
@@ -35,7 +72,7 @@ final class DeepLinkHandler {
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
- if messagePreview.count > 20000 {
+ if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
return
}
@@ -48,9 +85,18 @@ final class DeepLinkHandler {
}
self.lastPromptAt = Date()
- let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
+ if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle(
+ message: messagePreview,
+ allowUnattended: allowUnattended)
+ {
+ self.presentAlert(title: "Deep link blocked", message: error.localizedDescription)
+ return
+ }
+
+ let urlText = originalURL.absoluteString
+ let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText
let body =
- "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
+ "Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)"
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
}
@@ -59,7 +105,7 @@ final class DeepLinkHandler {
}
do {
- let channel = GatewayAgentChannel(raw: link.channel)
+ let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
@@ -72,9 +118,9 @@ final class DeepLinkHandler {
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
- deliver: channel.shouldDeliver(link.deliver),
- to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
- channel: channel,
+ deliver: effectiveDelivery.deliver,
+ to: effectiveDelivery.to,
+ channel: effectiveDelivery.channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
index 73ae0188a39..f85e8d1a5df 100644
--- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
@@ -1,8 +1,8 @@
import AppKit
-import OpenClawKit
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import OSLog
@MainActor
@@ -22,11 +22,6 @@ final class DevicePairingApprovalPrompter {
private var alertHostWindow: NSWindow?
private var resolvedByRequestId: Set = []
- private final class AlertHostWindow: NSWindow {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { true }
- }
-
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedDevice]?
@@ -55,7 +50,9 @@ final class DevicePairingApprovalPrompter {
let isRepair: Bool?
let ts: Double
- var id: String { self.requestId }
+ var id: String {
+ self.requestId
+ }
}
private struct PairingResolvedEvent: Codable {
@@ -231,35 +228,11 @@ final class DevicePairingApprovalPrompter {
}
private func endActiveAlert() {
- guard let alert = self.activeAlert else { return }
- if let parent = alert.window.sheetParent {
- parent.endSheet(alert.window, returnCode: .abort)
- }
- self.activeAlert = nil
- self.activeRequestId = nil
+ PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
}
private func requireAlertHostWindow() -> NSWindow {
- if let alertHostWindow {
- return alertHostWindow
- }
-
- let window = AlertHostWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
- styleMask: [.borderless],
- backing: .buffered,
- defer: false)
- window.title = ""
- window.isReleasedWhenClosed = false
- window.level = .floating
- window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
- window.isOpaque = false
- window.hasShadow = false
- window.backgroundColor = .clear
- window.ignoresMouseEvents = true
-
- self.alertHostWindow = window
- return window
+ PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
}
private func handle(push: GatewayPush) {
diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift
index 21ab5b1749f..f6bc8392503 100644
--- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift
+++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift
@@ -8,7 +8,9 @@ enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
case allowlist
case full
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
@@ -24,7 +26,9 @@ enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
case ask
case allow
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
@@ -67,7 +71,9 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
case onMiss = "on-miss"
case always
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
index add04c73087..670fa891c5b 100644
--- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import CoreGraphics
import Foundation
+import OpenClawKit
+import OpenClawProtocol
import OSLog
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
index c87dd1e5884..e1432aaea1c 100644
--- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
+++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
@@ -1,8 +1,8 @@
import AppKit
-import OpenClawKit
import CryptoKit
import Darwin
import Foundation
+import OpenClawKit
import OSLog
struct ExecApprovalPromptRequest: Codable, Sendable {
@@ -76,7 +76,9 @@ private struct ExecHostResponse: Codable {
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
- var errorDescription: String? { self.message }
+ var errorDescription: String? {
+ self.message
+ }
}
static func requestDecision(
diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift
index 4cf4d18b151..0d7d582dd33 100644
--- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift
@@ -1,7 +1,7 @@
+import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
-import Foundation
import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
@@ -24,9 +24,13 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
- var isDeliverable: Bool { self != .webchat }
+ var isDeliverable: Bool {
+ self != .webchat
+ }
- func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
+ func shouldDeliver(_ deliver: Bool) -> Bool {
+ deliver && self.isDeliverable
+ }
}
struct GatewayAgentInvocation: Sendable {
diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift
index 4becd8b13cd..281dcb9e8bd 100644
--- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift
@@ -1,5 +1,5 @@
-import OpenClawDiscovery
import Foundation
+import OpenClawDiscovery
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
@@ -15,19 +15,29 @@ enum GatewayDiscoveryHelpers {
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
- tailnetDns: gateway.tailnetDns,
+ serviceHost: gateway.serviceHost,
+ servicePort: gateway.servicePort,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
- tailnetDns: String?,
+ serviceHost: String?,
+ servicePort: Int?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
- if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
- return "wss://\(tailnetDns)"
+ // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
+ // Prefer the resolved service endpoint (SRV + A/AAAA).
+ if let host = self.trimmed(serviceHost), !host.isEmpty,
+ let port = servicePort, port > 0
+ {
+ let scheme = port == 443 ? "wss" : "ws"
+ let portSuffix = port == 443 ? "" : ":\(port)"
+ return "\(scheme)://\(host)\(portSuffix)"
}
+
+ // Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV.
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
index 20961e379bf..0edb2e65122 100644
--- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
@@ -619,7 +619,29 @@ actor GatewayEndpointStore {
}
extension GatewayEndpointStore {
- static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
+ private static func normalizeDashboardPath(_ rawPath: String?) -> String {
+ let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "/" }
+ let withLeadingSlash = trimmed.hasPrefix("/") ? trimmed : "/" + trimmed
+ guard withLeadingSlash != "/" else { return "/" }
+ return withLeadingSlash.hasSuffix("/") ? withLeadingSlash : withLeadingSlash + "/"
+ }
+
+ private static func localControlUiBasePath() -> String {
+ let root = OpenClawConfigFile.loadDict()
+ guard let gateway = root["gateway"] as? [String: Any],
+ let controlUi = gateway["controlUi"] as? [String: Any]
+ else {
+ return "/"
+ }
+ return self.normalizeDashboardPath(controlUi["basePath"] as? String)
+ }
+
+ static func dashboardURL(
+ for config: GatewayConnection.Config,
+ mode: AppState.ConnectionMode,
+ localBasePath: String? = nil) throws -> URL
+ {
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
@@ -633,7 +655,17 @@ extension GatewayEndpointStore {
default:
components.scheme = "http"
}
- components.path = "/"
+
+ let urlPath = self.normalizeDashboardPath(components.path)
+ if urlPath != "/" {
+ components.path = urlPath
+ } else if mode == .local {
+ let fallbackPath = localBasePath ?? self.localControlUiBasePath()
+ components.path = self.normalizeDashboardPath(fallbackPath)
+ } else {
+ components.path = "/"
+ }
+
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift
index 1e10394c2d2..059eb4da6e0 100644
--- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift
@@ -1,14 +1,16 @@
-import OpenClawIPC
import Foundation
+import OpenClawIPC
import OSLog
-// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
+/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
struct Semver: Comparable, CustomStringConvertible, Sendable {
let major: Int
let minor: Int
let patch: Int
- var description: String { "\(self.major).\(self.minor).\(self.patch)" }
+ var description: String {
+ "\(self.major).\(self.minor).\(self.patch)"
+ }
static func < (lhs: Semver, rhs: Semver) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
@@ -93,7 +95,7 @@ enum GatewayEnvironment {
return (trimmed?.isEmpty == false) ? trimmed : nil
}
- // Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
+ /// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
Semver.parse(versionString)
}
diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
index 03855b7698a..d55f7c1b015 100644
--- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift
+++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
@@ -1,8 +1,8 @@
import AppKit
+import Observation
import OpenClawDiscovery
import OpenClawIPC
import OpenClawKit
-import Observation
import SwiftUI
struct GeneralSettings: View {
@@ -16,8 +16,13 @@ struct GeneralSettings: View {
@State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
- private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
- private var remoteLabelWidth: CGFloat { 88 }
+ private var isNixMode: Bool {
+ ProcessInfo.processInfo.isNixMode
+ }
+
+ private var remoteLabelWidth: CGFloat {
+ 88
+ }
var body: some View {
ScrollView(.vertical) {
@@ -683,7 +688,9 @@ extension GeneralSettings {
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
- OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
+ OpenClawConfigFile.setRemoteGatewayUrl(
+ host: gateway.serviceHost ?? host,
+ port: gateway.servicePort ?? gateway.gatewayPort)
}
}
}
diff --git a/apps/macos/Sources/OpenClaw/HealthStore.swift b/apps/macos/Sources/OpenClaw/HealthStore.swift
index 4fb08f0c3da..22c1409fca7 100644
--- a/apps/macos/Sources/OpenClaw/HealthStore.swift
+++ b/apps/macos/Sources/OpenClaw/HealthStore.swift
@@ -89,8 +89,8 @@ final class HealthStore {
}
}
- // Test-only escape hatch: the HealthStore is a process-wide singleton but
- // state derivation is pure from `snapshot` + `lastError`.
+ /// Test-only escape hatch: the HealthStore is a process-wide singleton but
+ /// state derivation is pure from `snapshot` + `lastError`.
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
self.snapshot = snapshot
self.lastError = lastError
diff --git a/apps/macos/Sources/OpenClaw/IconState.swift b/apps/macos/Sources/OpenClaw/IconState.swift
index ec273858354..c2eab0e5010 100644
--- a/apps/macos/Sources/OpenClaw/IconState.swift
+++ b/apps/macos/Sources/OpenClaw/IconState.swift
@@ -72,7 +72,9 @@ enum IconOverrideSelection: String, CaseIterable, Identifiable {
case mainBash, mainRead, mainWrite, mainEdit, mainOther
case otherBash, otherRead, otherWrite, otherEdit, otherOther
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var label: String {
switch self {
diff --git a/apps/macos/Sources/OpenClaw/InstancesStore.swift b/apps/macos/Sources/OpenClaw/InstancesStore.swift
index 1f9dce6cb9a..566340337db 100644
--- a/apps/macos/Sources/OpenClaw/InstancesStore.swift
+++ b/apps/macos/Sources/OpenClaw/InstancesStore.swift
@@ -1,8 +1,8 @@
-import OpenClawKit
-import OpenClawProtocol
import Cocoa
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import OSLog
struct InstanceInfo: Identifiable, Codable {
@@ -158,7 +158,7 @@ final class InstancesStore {
private func localFallbackInstance(reason: String) -> InstanceInfo {
let host = Host.current().localizedName ?? "this-mac"
- let ip = Self.primaryIPv4Address()
+ let ip = SystemPresenceInfo.primaryIPv4Address()
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
@@ -172,58 +172,13 @@ final class InstancesStore {
platform: platform,
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
- lastInputSeconds: Self.lastInputSeconds(),
+ lastInputSeconds: SystemPresenceInfo.lastInputSeconds(),
mode: "local",
reason: reason,
text: text,
ts: ts)
}
- private static func lastInputSeconds() -> Int? {
- let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
- let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
- if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
- return Int(seconds.rounded())
- }
-
- private static func primaryIPv4Address() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- var fallback: String?
- var en0: String?
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let name = String(cString: ptr.pointee.ifa_name)
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
-
- if name == "en0" { en0 = ip; break }
- if fallback == nil { fallback = ip }
- }
-
- return en0 ?? fallback
- }
-
// MARK: - Helpers
/// Keep the last raw payload for logging.
diff --git a/apps/macos/Sources/OpenClaw/LogLocator.swift b/apps/macos/Sources/OpenClaw/LogLocator.swift
index 927b7892a28..b504ab02ace 100644
--- a/apps/macos/Sources/OpenClaw/LogLocator.swift
+++ b/apps/macos/Sources/OpenClaw/LogLocator.swift
@@ -7,8 +7,7 @@ enum LogLocator {
{
return URL(fileURLWithPath: override)
}
- let preferred = URL(fileURLWithPath: "/tmp/openclaw")
- return preferred
+ return URL(fileURLWithPath: "/tmp/openclaw")
}
private static var stdoutLog: URL {
diff --git a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
index bd46a8e6ff0..7692887e6c7 100644
--- a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
+++ b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
@@ -37,7 +37,9 @@ enum AppLogLevel: String, CaseIterable, Identifiable {
static let `default`: AppLogLevel = .info
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift
index 406d4e063dc..00e2a9be0a6 100644
--- a/apps/macos/Sources/OpenClaw/MenuBar.swift
+++ b/apps/macos/Sources/OpenClaw/MenuBar.swift
@@ -345,7 +345,7 @@ protocol UpdaterProviding: AnyObject {
func checkForUpdates(_ sender: Any?)
}
-// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
+/// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
final class DisabledUpdaterController: UpdaterProviding {
var automaticallyChecksForUpdates: Bool = false
var automaticallyDownloadsUpdates: Bool = false
@@ -394,7 +394,9 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
}
- var isAvailable: Bool { true }
+ var isAvailable: Bool {
+ true
+ }
func checkForUpdates(_ sender: Any?) {
self.controller.checkForUpdates(sender)
diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift
index 6dec4d93620..3416d23f812 100644
--- a/apps/macos/Sources/OpenClaw/MenuContentView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift
@@ -337,7 +337,7 @@ struct MenuContent: View {
private func openDashboard() async {
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
- let url = try GatewayEndpointStore.dashboardURL(for: config)
+ let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode)
NSWorkspace.shared.open(url)
} catch {
let alert = NSAlert()
@@ -400,7 +400,6 @@ struct MenuContent: View {
}
}
- @ViewBuilder
private func statusLine(label: String, color: Color) -> some View {
HStack(spacing: 6) {
Circle()
@@ -590,6 +589,8 @@ struct MenuContent: View {
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
- var id: String { self.uid }
+ var id: String {
+ self.uid
+ }
}
}
diff --git a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
index f1e85cba152..7107946989e 100644
--- a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
@@ -22,7 +22,9 @@ final class HighlightedMenuItemHostView: NSView {
}
@available(*, unavailable)
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
override var intrinsicContentSize: NSSize {
let size = self.hosting.fittingSize
diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift
index 9b6bb099341..37fd6ca2505 100644
--- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift
+++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift
@@ -159,7 +159,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector {
// MARK: - Injection
- private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
+ private var mainSessionKey: String {
+ WorkActivityStore.shared.mainSessionKey
+ }
private func inject(into menu: NSMenu) {
self.cancelPreviewTasks()
@@ -1175,8 +1177,7 @@ extension MenuSessionsInjector {
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
if highlighted {
- let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
- return container
+ return HighlightedMenuItemHostView(rootView: rootView, width: width)
}
let hosting = NSHostingView(rootView: rootView)
diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift
index af72740a676..e35057d28cf 100644
--- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift
+++ b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift
@@ -64,8 +64,7 @@ actor MicLevelMonitor {
}
let rms = sqrt(sum / Float(frameCount) + 1e-12)
let db = 20 * log10(Double(rms))
- let normalized = max(0, min(1, (db + 50) / 50))
- return normalized
+ return max(0, min(1, (db + 50) / 50))
}
}
diff --git a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift
index ff966e1eabc..b320c84d232 100644
--- a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift
+++ b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift
@@ -2,7 +2,10 @@ import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
- static var defaultPath: String { self.resolveDefaultPath() }
+ static var defaultPath: String {
+ self.resolveDefaultPath()
+ }
+
private static let logger = Logger(subsystem: "ai.openclaw", category: "models")
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
index db404aa6e17..bd4df512ca4 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
@@ -1,6 +1,6 @@
-import OpenClawKit
import CoreLocation
import Foundation
+import OpenClawKit
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
index eed0755f9b7..af46788c9cc 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
@@ -1,5 +1,5 @@
-import OpenClawKit
import Foundation
+import OpenClawKit
import OSLog
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
index 0b88f159098..60bd95f2894 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
@@ -1,7 +1,7 @@
import AppKit
+import Foundation
import OpenClawIPC
import OpenClawKit
-import Foundation
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift
index 982ec8bf90f..733410b1860 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift
@@ -1,6 +1,6 @@
-import OpenClawKit
import CoreLocation
import Foundation
+import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
index 98532946624..ee994b38f65 100644
--- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
@@ -1,10 +1,10 @@
import AppKit
+import Foundation
+import Observation
import OpenClawDiscovery
import OpenClawIPC
import OpenClawKit
import OpenClawProtocol
-import Foundation
-import Observation
import OSLog
import UserNotifications
@@ -38,11 +38,6 @@ final class NodePairingApprovalPrompter {
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private var autoApproveAttempts: Set = []
- private final class AlertHostWindow: NSWindow {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { true }
- }
-
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedNode]?
@@ -68,7 +63,9 @@ final class NodePairingApprovalPrompter {
let silent: Bool?
let ts: Double
- var id: String { self.requestId }
+ var id: String {
+ self.requestId
+ }
}
private struct PairingResolvedEvent: Codable {
@@ -235,35 +232,11 @@ final class NodePairingApprovalPrompter {
}
private func endActiveAlert() {
- guard let alert = self.activeAlert else { return }
- if let parent = alert.window.sheetParent {
- parent.endSheet(alert.window, returnCode: .abort)
- }
- self.activeAlert = nil
- self.activeRequestId = nil
+ PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
}
private func requireAlertHostWindow() -> NSWindow {
- if let alertHostWindow {
- return alertHostWindow
- }
-
- let window = AlertHostWindow(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
- styleMask: [.borderless],
- backing: .buffered,
- defer: false)
- window.title = ""
- window.isReleasedWhenClosed = false
- window.level = .floating
- window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
- window.isOpaque = false
- window.hasShadow = false
- window.backgroundColor = .clear
- window.ignoresMouseEvents = true
-
- self.alertHostWindow = window
- return window
+ PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
}
private func handle(push: GatewayPush) {
diff --git a/apps/macos/Sources/OpenClaw/NodesStore.swift b/apps/macos/Sources/OpenClaw/NodesStore.swift
index 6ea5fbe9087..5cc94858645 100644
--- a/apps/macos/Sources/OpenClaw/NodesStore.swift
+++ b/apps/macos/Sources/OpenClaw/NodesStore.swift
@@ -18,9 +18,17 @@ struct NodeInfo: Identifiable, Codable {
let paired: Bool?
let connected: Bool?
- var id: String { self.nodeId }
- var isConnected: Bool { self.connected ?? false }
- var isPaired: Bool { self.paired ?? false }
+ var id: String {
+ self.nodeId
+ }
+
+ var isConnected: Bool {
+ self.connected ?? false
+ }
+
+ var isPaired: Bool {
+ self.paired ?? false
+ }
}
private struct NodeListResponse: Codable {
diff --git a/apps/macos/Sources/OpenClaw/NotificationManager.swift b/apps/macos/Sources/OpenClaw/NotificationManager.swift
index f522e631764..b8e6fcddc8c 100644
--- a/apps/macos/Sources/OpenClaw/NotificationManager.swift
+++ b/apps/macos/Sources/OpenClaw/NotificationManager.swift
@@ -1,5 +1,5 @@
-import OpenClawIPC
import Foundation
+import OpenClawIPC
import Security
import UserNotifications
diff --git a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
index 1191c7e2222..31157b0d831 100644
--- a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
+++ b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
@@ -10,7 +10,9 @@ final class NotifyOverlayController {
static let shared = NotifyOverlayController()
private(set) var model = Model()
- var isVisible: Bool { self.model.isVisible }
+ var isVisible: Bool {
+ self.model.isVisible
+ }
struct Model {
var title: String = ""
diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift
index def8af4b219..b8a6377b419 100644
--- a/apps/macos/Sources/OpenClaw/Onboarding.swift
+++ b/apps/macos/Sources/OpenClaw/Onboarding.swift
@@ -1,9 +1,9 @@
import AppKit
+import Combine
+import Observation
import OpenClawChatUI
import OpenClawDiscovery
import OpenClawIPC
-import Combine
-import Observation
import SwiftUI
enum UIStrings {
@@ -142,18 +142,30 @@ struct OnboardingView: View {
Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat)
}
- var pageCount: Int { self.pageOrder.count }
+ var pageCount: Int {
+ self.pageOrder.count
+ }
+
var activePageIndex: Int {
self.activePageIndex(for: self.currentPage)
}
- var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
- var wizardPageOrderIndex: Int? { self.pageOrder.firstIndex(of: self.wizardPageIndex) }
+ var buttonTitle: String {
+ self.currentPage == self.pageCount - 1 ? "Finish" : "Next"
+ }
+
+ var wizardPageOrderIndex: Int? {
+ self.pageOrder.firstIndex(of: self.wizardPageIndex)
+ }
+
var isWizardBlocking: Bool {
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
}
- var canAdvance: Bool { !self.isWizardBlocking }
+ var canAdvance: Bool {
+ !self.isWizardBlocking
+ }
+
var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
return "npm install -g openclaw@\(version)"
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
index bfffc39f15e..ba43424aa9a 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
@@ -1,7 +1,7 @@
import AppKit
+import Foundation
import OpenClawDiscovery
import OpenClawIPC
-import Foundation
import SwiftUI
extension OnboardingView {
@@ -35,7 +35,9 @@ extension OnboardingView {
user: user,
host: host,
port: gateway.sshPort)
- OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
+ OpenClawConfigFile.setRemoteGatewayUrl(
+ host: gateway.serviceHost ?? host,
+ port: gateway.servicePort ?? gateway.gatewayPort)
}
self.state.remoteCliPath = gateway.cliPath ?? ""
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
index 64ddc332e4a..dfbdf91d44d 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
@@ -1,5 +1,5 @@
-import OpenClawIPC
import Foundation
+import OpenClawIPC
extension OnboardingView {
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index 309c4aa026e..5760bfff8c2 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -206,7 +206,9 @@ extension OnboardingView {
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
- if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
+ if let message = CommandResolver
+ .sshTargetValidationMessage(self.state.remoteTarget)
+ {
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift
index 51424fdb78c..0c77f1e327d 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Observation
+import OpenClawProtocol
import SwiftUI
extension OnboardingView {
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
index 0b413433666..1895b2af94f 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
@@ -23,7 +23,7 @@ extension OnboardingView {
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
- case let .unsafe(reason):
+ 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 case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) {
self.workspaceStatus = "Workspace not created: \(reason)"
return
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift
index 412826650a6..75b9522a4d1 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import OSLog
import SwiftUI
@@ -41,8 +41,13 @@ final class OnboardingWizardModel {
private var restartAttempts = 0
private let maxRestartAttempts = 1
- var isComplete: Bool { self.status == "done" }
- var isRunning: Bool { self.status == "running" }
+ var isComplete: Bool {
+ self.status == "done"
+ }
+
+ var isRunning: Bool {
+ self.status == "running"
+ }
func reset() {
self.sessionId = nil
@@ -408,5 +413,7 @@ private struct WizardOptionItem: Identifiable {
let index: Int
let option: WizardOption
- var id: Int { self.index }
+ var id: Int {
+ self.index
+ }
}
diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
index 3f7d3c03aa5..f49f2b7e0d4 100644
--- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
+++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
@@ -1,8 +1,9 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
enum OpenClawConfigFile {
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
+ private static let configAuditFileName = "config-audit.jsonl"
static func url() -> URL {
OpenClawPaths.configURL
@@ -35,15 +36,61 @@ enum OpenClawConfigFile {
static func saveDict(_ dict: [String: Any]) {
// Nix mode disables config writes in production, but tests rely on saving temp configs.
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
+ let url = self.url()
+ let previousData = try? Data(contentsOf: url)
+ let previousRoot = previousData.flatMap { self.parseConfigData($0) }
+ let previousBytes = previousData?.count
+ let hadMetaBefore = self.hasMeta(previousRoot)
+ let gatewayModeBefore = self.gatewayMode(previousRoot)
+
+ var output = dict
+ self.stampMeta(&output)
+
do {
- let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
- let url = self.url()
+ let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
+ let nextBytes = data.count
+ let gatewayModeAfter = self.gatewayMode(output)
+ let suspicious = self.configWriteSuspiciousReasons(
+ existsBefore: previousData != nil,
+ previousBytes: previousBytes,
+ nextBytes: nextBytes,
+ hadMetaBefore: hadMetaBefore,
+ gatewayModeBefore: gatewayModeBefore,
+ gatewayModeAfter: gatewayModeAfter)
+ if !suspicious.isEmpty {
+ self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
+ }
+ self.appendConfigWriteAudit([
+ "result": "success",
+ "configPath": url.path,
+ "existsBefore": previousData != nil,
+ "previousBytes": previousBytes ?? NSNull(),
+ "nextBytes": nextBytes,
+ "hasMetaBefore": hadMetaBefore,
+ "hasMetaAfter": self.hasMeta(output),
+ "gatewayModeBefore": gatewayModeBefore ?? NSNull(),
+ "gatewayModeAfter": gatewayModeAfter ?? NSNull(),
+ "suspicious": suspicious,
+ ])
} catch {
self.logger.error("config save failed: \(error.localizedDescription)")
+ self.appendConfigWriteAudit([
+ "result": "failed",
+ "configPath": url.path,
+ "existsBefore": previousData != nil,
+ "previousBytes": previousBytes ?? NSNull(),
+ "nextBytes": NSNull(),
+ "hasMetaBefore": hadMetaBefore,
+ "hasMetaAfter": self.hasMeta(output),
+ "gatewayModeBefore": gatewayModeBefore ?? NSNull(),
+ "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
+ "suspicious": [],
+ "error": error.localizedDescription,
+ ])
}
}
@@ -214,4 +261,100 @@ enum OpenClawConfigFile {
}
return nil
}
+
+ private static func stampMeta(_ root: inout [String: Any]) {
+ var meta = root["meta"] as? [String: Any] ?? [:]
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app"
+ meta["lastTouchedVersion"] = version
+ meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date())
+ root["meta"] = meta
+ }
+
+ private static func hasMeta(_ root: [String: Any]?) -> Bool {
+ guard let root else { return false }
+ return root["meta"] is [String: Any]
+ }
+
+ private static func hasMeta(_ root: [String: Any]) -> Bool {
+ root["meta"] is [String: Any]
+ }
+
+ private static func gatewayMode(_ root: [String: Any]?) -> String? {
+ guard let root else { return nil }
+ return self.gatewayMode(root)
+ }
+
+ private static func gatewayMode(_ root: [String: Any]) -> String? {
+ guard let gateway = root["gateway"] as? [String: Any],
+ let mode = gateway["mode"] as? String
+ else { return nil }
+ let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func configWriteSuspiciousReasons(
+ existsBefore: Bool,
+ previousBytes: Int?,
+ nextBytes: Int,
+ hadMetaBefore: Bool,
+ gatewayModeBefore: String?,
+ gatewayModeAfter: String?) -> [String]
+ {
+ var reasons: [String] = []
+ if !existsBefore {
+ return reasons
+ }
+ if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) {
+ reasons.append("size-drop:\(previousBytes)->\(nextBytes)")
+ }
+ if !hadMetaBefore {
+ reasons.append("missing-meta-before-write")
+ }
+ if gatewayModeBefore != nil, gatewayModeAfter == nil {
+ reasons.append("gateway-mode-removed")
+ }
+ return reasons
+ }
+
+ private static func configAuditLogURL() -> URL {
+ self.stateDirURL()
+ .appendingPathComponent("logs", isDirectory: true)
+ .appendingPathComponent(self.configAuditFileName, isDirectory: false)
+ }
+
+ private static func appendConfigWriteAudit(_ fields: [String: Any]) {
+ var record: [String: Any] = [
+ "ts": ISO8601DateFormatter().string(from: Date()),
+ "source": "macos-openclaw-config-file",
+ "event": "config.write",
+ "pid": ProcessInfo.processInfo.processIdentifier,
+ "argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
+ ]
+ for (key, value) in fields {
+ record[key] = value is NSNull ? NSNull() : value
+ }
+ guard JSONSerialization.isValidJSONObject(record),
+ let data = try? JSONSerialization.data(withJSONObject: record)
+ else {
+ return
+ }
+ var line = Data()
+ line.append(data)
+ line.append(0x0A)
+ let logURL = self.configAuditLogURL()
+ do {
+ try FileManager().createDirectory(
+ at: logURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ if !FileManager().fileExists(atPath: logURL.path) {
+ FileManager().createFile(atPath: logURL.path, contents: nil)
+ }
+ let handle = try FileHandle(forWritingTo: logURL)
+ defer { try? handle.close() }
+ try handle.seekToEnd()
+ try handle.write(contentsOf: line)
+ } catch {
+ // best-effort
+ }
+ }
}
diff --git a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift
index 632c07c802b..206031f9aa1 100644
--- a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift
+++ b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift
@@ -24,8 +24,7 @@ enum OpenClawPaths {
}
}
let home = FileManager().homeDirectoryForCurrentUser
- let preferred = home.appendingPathComponent(".openclaw", isDirectory: true)
- return preferred
+ return home.appendingPathComponent(".openclaw", isDirectory: true)
}
private static func resolveConfigCandidate(in dir: URL) -> URL? {
diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
new file mode 100644
index 00000000000..e8e4428bf3f
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
@@ -0,0 +1,46 @@
+import AppKit
+
+final class PairingAlertHostWindow: NSWindow {
+ override var canBecomeKey: Bool {
+ true
+ }
+
+ override var canBecomeMain: Bool {
+ true
+ }
+}
+
+@MainActor
+enum PairingAlertSupport {
+ static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
+ guard let alert = activeAlert else { return }
+ if let parent = alert.window.sheetParent {
+ parent.endSheet(alert.window, returnCode: .abort)
+ }
+ activeAlert = nil
+ activeRequestId = nil
+ }
+
+ static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
+ if let alertHostWindow {
+ return alertHostWindow
+ }
+
+ let window = PairingAlertHostWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
+ styleMask: [.borderless],
+ backing: .buffered,
+ defer: false)
+ window.title = ""
+ window.isReleasedWhenClosed = false
+ window.level = .floating
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ window.isOpaque = false
+ window.hasShadow = false
+ window.backgroundColor = .clear
+ window.ignoresMouseEvents = true
+
+ alertHostWindow = window
+ return window
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift
index 3cf1cba3f6e..b5bcd167a46 100644
--- a/apps/macos/Sources/OpenClaw/PermissionManager.swift
+++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift
@@ -1,11 +1,11 @@
import AppKit
import ApplicationServices
import AVFoundation
-import OpenClawIPC
import CoreGraphics
import CoreLocation
import Foundation
import Observation
+import OpenClawIPC
import Speech
import UserNotifications
@@ -336,7 +336,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
cont.resume(returning: status)
}
- // nonisolated for Swift 6 strict concurrency compatibility
+ /// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
@@ -344,7 +344,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
}
}
- // Legacy callback (still used on some macOS versions / configurations).
+ /// Legacy callback (still used on some macOS versions / configurations).
nonisolated func locationManager(
_ manager: CLLocationManager,
didChangeAuthorization status: CLAuthorizationStatus)
diff --git a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift
index a8f6accf8af..de15e5ebb63 100644
--- a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift
+++ b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift
@@ -1,6 +1,6 @@
+import CoreLocation
import OpenClawIPC
import OpenClawKit
-import CoreLocation
import SwiftUI
struct PermissionsSettings: View {
@@ -164,7 +164,9 @@ struct PermissionRow: View {
.padding(.vertical, self.compact ? 4 : 6)
}
- private var iconSize: CGFloat { self.compact ? 28 : 32 }
+ private var iconSize: CGFloat {
+ self.compact ? 28 : 32
+ }
private var title: String {
switch self.capability {
diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift
index 98225f30e1e..7ab7e8def3f 100644
--- a/apps/macos/Sources/OpenClaw/PortGuardian.swift
+++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift
@@ -103,7 +103,9 @@ actor PortGuardian {
let status: Status
let listeners: [ReportListener]
- var id: Int { self.port }
+ var id: Int {
+ self.port
+ }
var offenders: [ReportListener] {
if case let .interference(_, offenders) = self.status { return offenders }
@@ -141,7 +143,9 @@ actor PortGuardian {
let user: String?
let expected: Bool
- var id: Int32 { self.pid }
+ var id: Int32 {
+ self.pid
+ }
}
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
diff --git a/apps/macos/Sources/OpenClaw/PresenceReporter.swift b/apps/macos/Sources/OpenClaw/PresenceReporter.swift
index 16d70b8a92c..2e7a1d4c472 100644
--- a/apps/macos/Sources/OpenClaw/PresenceReporter.swift
+++ b/apps/macos/Sources/OpenClaw/PresenceReporter.swift
@@ -1,5 +1,4 @@
import Cocoa
-import Darwin
import Foundation
import OSLog
@@ -33,10 +32,10 @@ final class PresenceReporter {
private func push(reason: String) async {
let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue }
let host = InstanceIdentity.displayName
- let ip = Self.primaryIPv4Address() ?? "ip-unknown"
+ let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown"
let version = Self.appVersionString()
let platform = Self.platformString()
- let lastInput = Self.lastInputSeconds()
+ let lastInput = SystemPresenceInfo.lastInputSeconds()
let text = Self.composePresenceSummary(mode: mode, reason: reason)
var params: [String: AnyHashable] = [
"instanceId": AnyHashable(self.instanceId),
@@ -64,9 +63,9 @@ final class PresenceReporter {
private static func composePresenceSummary(mode: String, reason: String) -> String {
let host = InstanceIdentity.displayName
- let ip = Self.primaryIPv4Address() ?? "ip-unknown"
+ let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown"
let version = Self.appVersionString()
- let lastInput = Self.lastInputSeconds()
+ let lastInput = SystemPresenceInfo.lastInputSeconds()
let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown"
return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)"
}
@@ -87,50 +86,7 @@ final class PresenceReporter {
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
- private static func lastInputSeconds() -> Int? {
- let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
- let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
- if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
- return Int(seconds.rounded())
- }
-
- private static func primaryIPv4Address() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- var fallback: String?
- var en0: String?
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let name = String(cString: ptr.pointee.ifa_name)
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
-
- if name == "en0" { en0 = ip; break }
- if fallback == nil { fallback = ip }
- }
-
- return en0 ?? fallback
- }
+ // (SystemPresenceInfo) last input + primary IPv4.
}
#if DEBUG
@@ -148,11 +104,11 @@ extension PresenceReporter {
}
static func _testLastInputSeconds() -> Int? {
- self.lastInputSeconds()
+ SystemPresenceInfo.lastInputSeconds()
}
static func _testPrimaryIPv4Address() -> String? {
- self.primaryIPv4Address()
+ SystemPresenceInfo.primaryIPv4Address()
}
}
#endif
diff --git a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift
index d05e593388e..a219f495336 100644
--- a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift
+++ b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift
@@ -12,8 +12,8 @@ extension ProcessInfo {
environment: [String: String],
standard: UserDefaults,
stableSuite: UserDefaults?,
- isAppBundle: Bool
- ) -> Bool {
+ isAppBundle: Bool) -> Bool
+ {
if environment["OPENCLAW_NIX_MODE"] == "1" { return true }
if standard.bool(forKey: "openclaw.nixMode") { return true }
diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist
index 51081d43df5..37c85b6f3dd 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.13
+ 2026.2.16
CFBundleVersion
- 202602130
+ 202602160
CFBundleIconFile
OpenClaw
CFBundleURLTypes
diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift
index 8ec23a067be..3112f57879b 100644
--- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift
+++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift
@@ -10,7 +10,9 @@ struct RuntimeVersion: Comparable, CustomStringConvertible {
let minor: Int
let patch: Int
- var description: String { "\(self.major).\(self.minor).\(self.patch)" }
+ var description: String {
+ "\(self.major).\(self.minor).\(self.patch)"
+ }
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
@@ -163,5 +165,7 @@ enum RuntimeLocator {
}
extension RuntimeKind {
- fileprivate var binaryName: String { "node" }
+ fileprivate var binaryName: String {
+ "node"
+ }
}
diff --git a/apps/macos/Sources/OpenClaw/SessionData.swift b/apps/macos/Sources/OpenClaw/SessionData.swift
index defd4fe8aa1..8234cbdef85 100644
--- a/apps/macos/Sources/OpenClaw/SessionData.swift
+++ b/apps/macos/Sources/OpenClaw/SessionData.swift
@@ -84,8 +84,13 @@ struct SessionRow: Identifiable {
let tokens: SessionTokenStats
let model: String?
- var ageText: String { relativeAge(from: self.updatedAt) }
- var label: String { self.displayName ?? self.key }
+ var ageText: String {
+ relativeAge(from: self.updatedAt)
+ }
+
+ var label: String {
+ self.displayName ?? self.key
+ }
var flagLabels: [String] {
var flags: [String] = []
diff --git a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
index 1cbeedd392d..51646e0a36a 100644
--- a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
+++ b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
@@ -1,14 +1,7 @@
import SwiftUI
-private struct MenuItemHighlightedKey: EnvironmentKey {
- static let defaultValue = false
-}
-
extension EnvironmentValues {
- var menuItemHighlighted: Bool {
- get { self[MenuItemHighlightedKey.self] }
- set { self[MenuItemHighlightedKey.self] = newValue }
- }
+ @Entry var menuItemHighlighted: Bool = false
}
struct SessionMenuLabelView: View {
diff --git a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift b/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift
index dc129df9f41..8840bce5569 100644
--- a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift
+++ b/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift
@@ -183,7 +183,6 @@ struct SessionMenuPreviewView: View {
.frame(width: max(1, self.width), alignment: .leading)
}
- @ViewBuilder
private func previewRow(_ item: SessionPreviewItem) -> some View {
HStack(alignment: .top, spacing: 4) {
Text(item.role.label)
@@ -212,7 +211,6 @@ struct SessionMenuPreviewView: View {
}
}
- @ViewBuilder
private func placeholder(_ text: String) -> some View {
Text(text)
.font(.caption)
@@ -227,7 +225,9 @@ enum SessionMenuPreviewLoader {
private static let previewMaxChars = 240
private struct PreviewTimeoutError: LocalizedError {
- var errorDescription: String? { "preview timeout" }
+ var errorDescription: String? {
+ "preview timeout"
+ }
}
static func prewarm(sessionKeys: [String], maxItems: Int) async {
diff --git a/apps/macos/Sources/OpenClaw/SessionsSettings.swift b/apps/macos/Sources/OpenClaw/SessionsSettings.swift
index 4a2a0e81e02..826f1128f54 100644
--- a/apps/macos/Sources/OpenClaw/SessionsSettings.swift
+++ b/apps/macos/Sources/OpenClaw/SessionsSettings.swift
@@ -85,7 +85,6 @@ struct SessionsSettings: View {
}
}
- @ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
diff --git a/apps/macos/Sources/OpenClaw/ShellExecutor.swift b/apps/macos/Sources/OpenClaw/ShellExecutor.swift
index 9633f0f8da0..ec757441a15 100644
--- a/apps/macos/Sources/OpenClaw/ShellExecutor.swift
+++ b/apps/macos/Sources/OpenClaw/ShellExecutor.swift
@@ -1,5 +1,5 @@
-import OpenClawIPC
import Foundation
+import OpenClawIPC
enum ShellExecutor {
struct ShellResult {
@@ -69,7 +69,7 @@ enum ShellExecutor {
if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000)
- let result = await withTaskGroup(of: ShellResult.self) { group in
+ return await withTaskGroup(of: ShellResult.self) { group in
group.addTask { await waitTask.value }
group.addTask {
try? await Task.sleep(nanoseconds: nanos)
@@ -87,7 +87,6 @@ enum ShellExecutor {
group.cancelAll()
return first
}
- return result
}
return await waitTask.value
diff --git a/apps/macos/Sources/OpenClaw/SkillsModels.swift b/apps/macos/Sources/OpenClaw/SkillsModels.swift
index 1fb40d99f15..d143484c40f 100644
--- a/apps/macos/Sources/OpenClaw/SkillsModels.swift
+++ b/apps/macos/Sources/OpenClaw/SkillsModels.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Foundation
+import OpenClawProtocol
struct SkillsStatusReport: Codable {
let workspaceDir: String
@@ -25,7 +25,9 @@ struct SkillStatus: Codable, Identifiable {
let configChecks: [SkillStatusConfigCheck]
let install: [SkillInstallOption]
- var id: String { self.name }
+ var id: String {
+ self.name
+ }
}
struct SkillRequirements: Codable {
@@ -45,7 +47,9 @@ struct SkillStatusConfigCheck: Codable, Identifiable {
let value: AnyCodable?
let satisfied: Bool
- var id: String { self.path }
+ var id: String {
+ self.path
+ }
}
struct SkillInstallOption: Codable, Identifiable {
diff --git a/apps/macos/Sources/OpenClaw/SkillsSettings.swift b/apps/macos/Sources/OpenClaw/SkillsSettings.swift
index 83aaa66c55d..02db8495112 100644
--- a/apps/macos/Sources/OpenClaw/SkillsSettings.swift
+++ b/apps/macos/Sources/OpenClaw/SkillsSettings.swift
@@ -1,5 +1,5 @@
-import OpenClawProtocol
import Observation
+import OpenClawProtocol
import SwiftUI
struct SkillsSettings: View {
@@ -142,7 +142,9 @@ private enum SkillsFilter: String, CaseIterable, Identifiable {
case needsSetup
case disabled
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
@@ -171,24 +173,16 @@ private struct SkillRow: View {
let onInstall: (SkillInstallOption, InstallTarget) -> Void
let onSetEnv: (String, Bool) -> Void
- private var missingBins: [String] { self.skill.missing.bins }
- private var missingEnv: [String] { self.skill.missing.env }
- private var missingConfig: [String] { self.skill.missing.config }
+ private var missingBins: [String] {
+ self.skill.missing.bins
+ }
- init(
- skill: SkillStatus,
- isBusy: Bool,
- connectionMode: AppState.ConnectionMode,
- onToggleEnabled: @escaping (Bool) -> Void,
- onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
- onSetEnv: @escaping (String, Bool) -> Void)
- {
- self.skill = skill
- self.isBusy = isBusy
- self.connectionMode = connectionMode
- self.onToggleEnabled = onToggleEnabled
- self.onInstall = onInstall
- self.onSetEnv = onSetEnv
+ private var missingEnv: [String] {
+ self.skill.missing.env
+ }
+
+ private var missingConfig: [String] {
+ self.skill.missing.config
}
var body: some View {
@@ -274,7 +268,6 @@ private struct SkillRow: View {
set: { self.onToggleEnabled($0) })
}
- @ViewBuilder
private var missingSummary: some View {
VStack(alignment: .leading, spacing: 4) {
if self.shouldShowMissingBins {
@@ -295,7 +288,6 @@ private struct SkillRow: View {
}
}
- @ViewBuilder
private var configChecksView: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(self.skill.configChecks) { check in
@@ -326,7 +318,6 @@ private struct SkillRow: View {
}
}
- @ViewBuilder
private var trailingActions: some View {
VStack(alignment: .trailing, spacing: 8) {
if !self.installOptions.isEmpty {
@@ -438,7 +429,9 @@ private struct EnvEditorState: Identifiable {
let envKey: String
let isPrimary: Bool
- var id: String { "\(self.skillKey)::\(self.envKey)" }
+ var id: String {
+ "\(self.skillKey)::\(self.envKey)"
+ }
}
private struct EnvEditorView: View {
diff --git a/apps/macos/Sources/OpenClaw/SoundEffects.swift b/apps/macos/Sources/OpenClaw/SoundEffects.swift
index b321238295d..37df8455f8f 100644
--- a/apps/macos/Sources/OpenClaw/SoundEffects.swift
+++ b/apps/macos/Sources/OpenClaw/SoundEffects.swift
@@ -10,7 +10,9 @@ enum SoundEffectCatalog {
return ["Glass"] + sorted
}
- static func displayName(for raw: String) -> String { raw }
+ static func displayName(for raw: String) -> String {
+ raw
+ }
static func url(for name: String) -> URL? {
self.discoveredSoundMap[name]
diff --git a/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift b/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift
new file mode 100644
index 00000000000..843ed371fb5
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift
@@ -0,0 +1,16 @@
+import CoreGraphics
+import Foundation
+import OpenClawKit
+
+enum SystemPresenceInfo {
+ static func lastInputSeconds() -> Int? {
+ let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
+ let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
+ if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
+ return Int(seconds.rounded())
+ }
+
+ static func primaryIPv4Address() -> String? {
+ NetworkInterfaces.primaryIPv4Address()
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift
index eef826c3f0c..b9bd6bd0c8c 100644
--- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift
+++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift
@@ -150,7 +150,9 @@ private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
case policy
case allowlist
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var title: String {
switch self {
diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift
index c1a3a3489a6..c9354d38bc2 100644
--- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift
+++ b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift
@@ -5,7 +5,9 @@ private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
case serve
case funnel
- var id: String { self.rawValue }
+ var id: String {
+ self.rawValue
+ }
var label: String {
switch self {
diff --git a/apps/macos/Sources/OpenClaw/TailscaleService.swift b/apps/macos/Sources/OpenClaw/TailscaleService.swift
index b7f716a4270..2cefa69d59d 100644
--- a/apps/macos/Sources/OpenClaw/TailscaleService.swift
+++ b/apps/macos/Sources/OpenClaw/TailscaleService.swift
@@ -1,10 +1,8 @@
import AppKit
import Foundation
import Observation
+import OpenClawDiscovery
import os
-#if canImport(Darwin)
-import Darwin
-#endif
/// Manages Tailscale integration and status checking.
@Observable
@@ -140,7 +138,7 @@ final class TailscaleService {
self.logger.info("Tailscale API not responding; app likely not running")
}
- if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
+ if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
@@ -178,49 +176,7 @@ final class TailscaleService {
}
}
- private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
- let parts = address.split(separator: ".")
- guard parts.count == 4 else { return false }
- let octets = parts.compactMap { Int($0) }
- guard octets.count == 4 else { return false }
- let a = octets[0]
- let b = octets[1]
- return a == 100 && b >= 64 && b <= 127
- }
-
- private nonisolated static func detectTailnetIPv4() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
- if Self.isTailnetIPv4(ip) { return ip }
- }
-
- return nil
- }
-
nonisolated static func fallbackTailnetIPv4() -> String? {
- self.detectTailnetIPv4()
+ TailscaleNetwork.detectTailnetIPv4()
}
}
diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift
index 9ef7b010fa8..47b041a5873 100644
--- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift
+++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift
@@ -1,7 +1,7 @@
import AVFoundation
+import Foundation
import OpenClawChatUI
import OpenClawKit
-import Foundation
import OSLog
import Speech
diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
index a24ba174374..80599d55ec3 100644
--- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
+++ b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
@@ -99,8 +99,13 @@ private final class OrbInteractionNSView: NSView {
private var didDrag = false
private var suppressSingleClick = false
- override var acceptsFirstResponder: Bool { true }
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+ override var acceptsFirstResponder: Bool {
+ true
+ }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
override func mouseDown(with event: NSEvent) {
self.mouseDownEvent = event
diff --git a/apps/macos/Sources/OpenClaw/UsageData.swift b/apps/macos/Sources/OpenClaw/UsageData.swift
index 7800054c66c..3886c966edb 100644
--- a/apps/macos/Sources/OpenClaw/UsageData.swift
+++ b/apps/macos/Sources/OpenClaw/UsageData.swift
@@ -41,8 +41,7 @@ struct UsageRow: Identifiable {
var remainingPercent: Int? {
guard let usedPercent, usedPercent.isFinite else { return nil }
- let remaining = max(0, min(100, Int(round(100 - usedPercent))))
- return remaining
+ return max(0, min(100, Int(round(100 - usedPercent))))
}
func detailText(now: Date = .init()) -> String {
diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
index 819bafd1271..e535ebd6616 100644
--- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
+++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
@@ -122,7 +122,7 @@ actor VoicePushToTalk {
private var recognitionTask: SFSpeechRecognitionTask?
private var tapInstalled = false
- // Session token used to drop stale callbacks when a new capture starts.
+ /// Session token used to drop stale callbacks when a new capture starts.
private var sessionID = UUID()
private var committed: String = ""
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift
index c41ecf4fd43..8a258389976 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift
@@ -28,7 +28,9 @@ enum VoiceWakeChime: Codable, Equatable, Sendable {
enum VoiceWakeChimeCatalog {
/// Options shown in the picker.
- static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
+ static var systemOptions: [String] {
+ SoundEffectCatalog.systemOptions
+ }
static func displayName(for raw: String) -> String {
SoundEffectCatalog.displayName(for: raw)
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
index fd888c8aa4f..af4fae356ee 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
@@ -1,5 +1,5 @@
-import OpenClawKit
import Foundation
+import OpenClawKit
import OSLog
@MainActor
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlay.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlay.swift
index 7e5ffe76c10..04bbfd69db0 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlay.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlay.swift
@@ -18,7 +18,9 @@ final class VoiceWakeOverlayController {
enum Source: String { case wakeWord, pushToTalk }
var model = Model()
- var isVisible: Bool { self.model.isVisible }
+ var isVisible: Bool {
+ self.model.isVisible
+ }
struct Model {
var text: String = ""
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift
index 151db8c9324..8e88c86d45d 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift
@@ -11,7 +11,9 @@ struct TranscriptTextView: NSViewRepresentable {
var onEndEditing: () -> Void
var onSend: () -> Void
- func makeCoordinator() -> Coordinator { Coordinator(self) }
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
func makeNSView(context: Context) -> NSScrollView {
let textView = TranscriptNSTextView()
@@ -77,7 +79,9 @@ struct TranscriptTextView: NSViewRepresentable {
var parent: TranscriptTextView
var isProgrammaticUpdate = false
- init(_ parent: TranscriptTextView) { self.parent = parent }
+ init(_ parent: TranscriptTextView) {
+ self.parent = parent
+ }
func textDidBeginEditing(_ notification: Notification) {
self.parent.onBeginEditing()
@@ -147,7 +151,9 @@ private final class ClickCatcher: NSView {
}
@available(*, unavailable)
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift
index 48055c10a6c..516da776ace 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift
@@ -131,7 +131,9 @@ private struct OverlayBackground: View {
}
extension OverlayBackground: @MainActor Equatable {
- static func == (lhs: Self, rhs: Self) -> Bool { true }
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ true
+ }
}
struct CloseHoverButton: View {
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
index 7ef86c28507..61f913b9da8 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
@@ -48,10 +48,10 @@ actor VoiceWakeRuntime {
private var isStarting: Bool = false
private var triggerOnlyTask: Task?
- // Tunables
- // Silence threshold once we've captured user speech (post-trigger).
+ /// Tunables
+ /// Silence threshold once we've captured user speech (post-trigger).
private let silenceWindow: TimeInterval = 2.0
- // Silence threshold when we only heard the trigger but no post-trigger speech yet.
+ /// Silence threshold when we only heard the trigger but no post-trigger speech yet.
private let triggerOnlySilenceWindow: TimeInterval = 5.0
// Maximum capture duration from trigger until we force-send, to avoid runaway sessions.
private let captureHardStop: TimeInterval = 120.0
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
index ca4f4a20355..d4413618e11 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
@@ -29,7 +29,9 @@ struct VoiceWakeSettings: View {
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
- var id: String { self.uid }
+ var id: String {
+ self.uid
+ }
}
private struct TriggerEntry: Identifiable {
diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift
index 2f77692de82..61d1b4d39b7 100644
--- a/apps/macos/Sources/OpenClaw/WebChatManager.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift
@@ -3,8 +3,13 @@ import Foundation
/// A borderless panel that can still accept key focus (needed for typing).
final class WebChatPanel: NSPanel {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { true }
+ override var canBecomeKey: Bool {
+ true
+ }
+
+ override var canBecomeMain: Bool {
+ true
+ }
}
enum WebChatPresentation {
diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
index d6b4417f06a..5b866304b09 100644
--- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
@@ -1,8 +1,8 @@
import AppKit
+import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
-import Foundation
import OSLog
import QuartzCore
import SwiftUI
diff --git a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
index b6fd97477fc..77d62963030 100644
--- a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
+++ b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import Foundation
import Observation
+import OpenClawKit
+import OpenClawProtocol
import SwiftUI
@MainActor
@@ -31,7 +31,9 @@ final class WorkActivityStore {
private var mainSessionKeyStorage = "main"
private let toolResultGrace: TimeInterval = 2.0
- var mainSessionKey: String { self.mainSessionKeyStorage }
+ var mainSessionKey: String {
+ self.mainSessionKeyStorage
+ }
func handleJob(sessionKey: String, state: String) {
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
index c8cde804ece..abd18efaa9a 100644
--- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
+++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
import Foundation
import Network
import Observation
+import OpenClawKit
import OSLog
@MainActor
@@ -18,8 +18,14 @@ public final class GatewayDiscoveryModel {
}
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
- public var id: String { self.stableID }
+ public var id: String {
+ self.stableID
+ }
+
public var displayName: String
+ // Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing.
+ public var serviceHost: String?
+ public var servicePort: Int?
public var lanHost: String?
public var tailnetDns: String?
public var sshPort: Int
@@ -31,6 +37,8 @@ public final class GatewayDiscoveryModel {
public init(
displayName: String,
+ serviceHost: String? = nil,
+ servicePort: Int? = nil,
lanHost: String? = nil,
tailnetDns: String? = nil,
sshPort: Int,
@@ -41,6 +49,8 @@ public final class GatewayDiscoveryModel {
isLocal: Bool)
{
self.displayName = displayName
+ self.serviceHost = serviceHost
+ self.servicePort = servicePort
self.lanHost = lanHost
self.tailnetDns = tailnetDns
self.sshPort = sshPort
@@ -62,8 +72,8 @@ public final class GatewayDiscoveryModel {
private var localIdentity: LocalIdentity
private let localDisplayName: String?
private let filterLocalGateways: Bool
- private var resolvedTXTByID: [String: [String: String]] = [:]
- private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
+ private var resolvedServiceByID: [String: ResolvedGatewayService] = [:]
+ private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var wideAreaFallbackTask: Task?
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
@@ -133,9 +143,9 @@ public final class GatewayDiscoveryModel {
self.resultsByDomain = [:]
self.gatewaysByDomain = [:]
self.statesByDomain = [:]
- self.resolvedTXTByID = [:]
- self.pendingTXTResolvers.values.forEach { $0.cancel() }
- self.pendingTXTResolvers = [:]
+ self.resolvedServiceByID = [:]
+ self.pendingServiceResolvers.values.forEach { $0.cancel() }
+ self.pendingServiceResolvers = [:]
self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = []
@@ -154,6 +164,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity)
return DiscoveredGateway(
displayName: beacon.displayName,
+ serviceHost: beacon.host,
+ servicePort: beacon.port,
lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22,
@@ -195,7 +207,8 @@ public final class GatewayDiscoveryModel {
let decodedName = BonjourEscapes.decode(name)
let stableID = GatewayEndpointID.stableID(result.endpoint)
- let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
+ let resolved = self.resolvedServiceByID[stableID]
+ let resolvedTXT = resolved?.txt ?? [:]
let txt = Self.txtDictionary(from: result).merging(
resolvedTXT,
uniquingKeysWith: { _, new in new })
@@ -208,8 +221,10 @@ public final class GatewayDiscoveryModel {
let parsedTXT = Self.parseGatewayTXT(txt)
- if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
- self.ensureTXTResolution(
+ // Always attempt NetService resolution for the endpoint (host/port and TXT).
+ // TXT is unauthenticated; do not use it for routing.
+ if resolved == nil {
+ self.ensureServiceResolution(
stableID: stableID,
serviceName: name,
type: type,
@@ -224,6 +239,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity)
return DiscoveredGateway(
displayName: prettyName,
+ serviceHost: resolved?.host,
+ servicePort: resolved?.port,
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
@@ -312,43 +329,9 @@ public final class GatewayDiscoveryModel {
}
private func updateStatusText() {
- let states = Array(self.statesByDomain.values)
- if states.isEmpty {
- self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
- return
- }
-
- if let failed = states.first(where: { state in
- if case .failed = state { return true }
- return false
- }) {
- if case let .failed(err) = failed {
- self.statusText = "Failed: \(err)"
- return
- }
- }
-
- if let waiting = states.first(where: { state in
- if case .waiting = state { return true }
- return false
- }) {
- if case let .waiting(err) = waiting {
- self.statusText = "Waiting: \(err)"
- return
- }
- }
-
- if states.contains(where: { if case .ready = $0 { true } else { false } }) {
- self.statusText = "Searching…"
- return
- }
-
- if states.contains(where: { if case .setup = $0 { true } else { false } }) {
- self.statusText = "Setup"
- return
- }
-
- self.statusText = "Searching…"
+ self.statusText = GatewayDiscoveryStatusText.make(
+ states: Array(self.statesByDomain.values),
+ hasBrowsers: !self.browsers.isEmpty)
}
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
@@ -421,16 +404,16 @@ public final class GatewayDiscoveryModel {
return target
}
- private func ensureTXTResolution(
+ private func ensureServiceResolution(
stableID: String,
serviceName: String,
type: String,
domain: String)
{
- guard self.resolvedTXTByID[stableID] == nil else { return }
- guard self.pendingTXTResolvers[stableID] == nil else { return }
+ guard self.resolvedServiceByID[stableID] == nil else { return }
+ guard self.pendingServiceResolvers[stableID] == nil else { return }
- let resolver = GatewayTXTResolver(
+ let resolver = GatewayServiceResolver(
name: serviceName,
type: type,
domain: domain,
@@ -438,10 +421,10 @@ public final class GatewayDiscoveryModel {
{ [weak self] result in
Task { @MainActor in
guard let self else { return }
- self.pendingTXTResolvers[stableID] = nil
+ self.pendingServiceResolvers[stableID] = nil
switch result {
- case let .success(txt):
- self.resolvedTXTByID[stableID] = txt
+ case let .success(resolved):
+ self.resolvedServiceByID[stableID] = resolved
self.updateGatewaysForAllDomains()
self.recomputeGateways()
case .failure:
@@ -450,7 +433,7 @@ public final class GatewayDiscoveryModel {
}
}
- self.pendingTXTResolvers[stableID] = resolver
+ self.pendingServiceResolvers[stableID] = resolver
resolver.start()
}
@@ -607,9 +590,15 @@ public final class GatewayDiscoveryModel {
}
}
-final class GatewayTXTResolver: NSObject, NetServiceDelegate {
+struct ResolvedGatewayService: Equatable, Sendable {
+ var txt: [String: String]
+ var host: String?
+ var port: Int?
+}
+
+final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService
- private let completion: (Result<[String: String], Error>) -> Void
+ private let completion: (Result) -> Void
private let logger: Logger
private var didFinish = false
@@ -618,7 +607,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
type: String,
domain: String,
logger: Logger,
- completion: @escaping (Result<[String: String], Error>) -> Void)
+ completion: @escaping (Result) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
@@ -633,24 +622,27 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
}
func cancel() {
- self.finish(result: .failure(GatewayTXTResolverError.cancelled))
+ self.finish(result: .failure(GatewayServiceResolverError.cancelled))
}
func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData())
+ let host = Self.normalizeHost(sender.hostName)
+ let port = sender.port > 0 ? sender.port : nil
if !txt.isEmpty {
let payload = self.formatTXT(txt)
self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
}
- self.finish(result: .success(txt))
+ let resolved = ResolvedGatewayService(txt: txt, host: host, port: port)
+ self.finish(result: .success(resolved))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
- self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
+ self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict)))
}
- private func finish(result: Result<[String: String], Error>) {
+ private func finish(result: Result) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
@@ -671,6 +663,12 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
return out
}
+ private static func normalizeHost(_ raw: String?) -> String? {
+ let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty { return nil }
+ return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ }
+
private func formatTXT(_ txt: [String: String]) -> String {
txt.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\($0.value)" }
@@ -678,7 +676,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
}
}
-enum GatewayTXTResolverError: Error {
+enum GatewayServiceResolverError: Error {
case cancelled
case resolveFailed([String: NSNumber])
}
diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
new file mode 100644
index 00000000000..60b11306d05
--- /dev/null
+++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
@@ -0,0 +1,47 @@
+import Darwin
+import Foundation
+
+public enum TailscaleNetwork {
+ public static func isTailnetIPv4(_ address: String) -> Bool {
+ let parts = address.split(separator: ".")
+ guard parts.count == 4 else { return false }
+ let octets = parts.compactMap { Int($0) }
+ guard octets.count == 4 else { return false }
+ let a = octets[0]
+ let b = octets[1]
+ return a == 100 && b >= 64 && b <= 127
+ }
+
+ public static func detectTailnetIPv4() -> String? {
+ var addrList: UnsafeMutablePointer?
+ guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
+ defer { freeifaddrs(addrList) }
+
+ for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
+ let flags = Int32(ptr.pointee.ifa_flags)
+ let isUp = (flags & IFF_UP) != 0
+ let isLoopback = (flags & IFF_LOOPBACK) != 0
+ let family = ptr.pointee.ifa_addr.pointee.sa_family
+ if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
+
+ var addr = ptr.pointee.ifa_addr.pointee
+ var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
+ let result = getnameinfo(
+ &addr,
+ socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
+ &buffer,
+ socklen_t(buffer.count),
+ nil,
+ 0,
+ NI_NUMERICHOST)
+ guard result == 0 else { continue }
+ let len = buffer.prefix { $0 != 0 }
+ let bytes = len.map { UInt8(bitPattern: $0) }
+ guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
+ if self.isTailnetIPv4(ip) { return ip }
+ }
+
+ return nil
+ }
+}
+
diff --git a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift
index bacff45d604..fea0aca91c1 100644
--- a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift
+++ b/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift
@@ -1,5 +1,5 @@
-import OpenClawKit
import Foundation
+import OpenClawKit
struct WideAreaGatewayBeacon: Sendable, Equatable {
var instanceName: String
@@ -117,13 +117,12 @@ enum WideAreaGatewayDiscovery {
}
var seen = Set()
- let ordered = ips.filter { value in
+ return ips.filter { value in
guard self.isTailnetIPv4(value) else { return false }
if seen.contains(value) { return false }
seen.insert(value)
return true
}
- return ordered
}
private static func readTailscaleStatus() -> String? {
@@ -370,5 +369,7 @@ private struct TailscaleStatus: Decodable {
}
extension Collection {
- fileprivate var nonEmpty: Self? { isEmpty ? nil : self }
+ fileprivate var nonEmpty: Self? {
+ isEmpty ? nil : self
+ }
}
diff --git a/apps/macos/Sources/OpenClawIPC/IPC.swift b/apps/macos/Sources/OpenClawIPC/IPC.swift
index 9560699d47f..13fbe8756ab 100644
--- a/apps/macos/Sources/OpenClawIPC/IPC.swift
+++ b/apps/macos/Sources/OpenClawIPC/IPC.swift
@@ -407,11 +407,10 @@ extension Request: Codable {
}
}
-// Shared transport settings
+/// Shared transport settings
public let controlSocketPath: String = {
let home = FileManager().homeDirectoryForCurrentUser
- let preferred = home
+ return home
.appendingPathComponent("Library/Application Support/OpenClaw/control.sock")
.path
- return preferred
}()
diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
index 1c31ce3b051..0989164a01e 100644
--- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
+++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
@@ -1,9 +1,7 @@
+import Foundation
+import OpenClawDiscovery
import OpenClawKit
import OpenClawProtocol
-import Foundation
-#if canImport(Darwin)
-import Darwin
-#endif
struct ConnectOptions {
var url: String?
@@ -301,7 +299,7 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let tailnetIP = detectTailnetIPv4()
+ let tailnetIP = TailscaleNetwork.detectTailnetIPv4()
switch normalized {
case "tailnet":
return tailnetIP ?? "127.0.0.1"
@@ -309,45 +307,3 @@ private func resolveLocalHost(bind: String?) -> String {
return "127.0.0.1"
}
}
-
-private func detectTailnetIPv4() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
- if isTailnetIPv4(ip) { return ip }
- }
-
- return nil
-}
-
-private func isTailnetIPv4(_ address: String) -> Bool {
- let parts = address.split(separator: ".")
- guard parts.count == 4 else { return false }
- let octets = parts.compactMap { Int($0) }
- guard octets.count == 4 else { return false }
- let a = octets[0]
- let b = octets[1]
- return a == 100 && b >= 64 && b <= 127
-}
diff --git a/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift b/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift
index 09ef2bbc051..b039ecdf411 100644
--- a/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift
+++ b/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift
@@ -1,5 +1,5 @@
-import OpenClawDiscovery
import Foundation
+import OpenClawDiscovery
struct DiscoveryOptions {
var timeoutMs: Int = 2000
diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
index 898a8a31cfa..0a73fc2108c 100644
--- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
+++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
@@ -1,7 +1,7 @@
-import OpenClawKit
-import OpenClawProtocol
import Darwin
import Foundation
+import OpenClawKit
+import OpenClawProtocol
struct WizardCliOptions {
var url: String?
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index fca8eac3a93..13ea8ecc15e 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable {
public let configpath: String?
public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
+ public let authmode: AnyCodable?
public init(
presence: [PresenceEntry],
@@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable {
uptimems: Int,
configpath: String?,
statedir: String?,
- sessiondefaults: [String: AnyCodable]?
+ sessiondefaults: [String: AnyCodable]?,
+ authmode: AnyCodable?
) {
self.presence = presence
self.health = health
@@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable {
self.configpath = configpath
self.statedir = statedir
self.sessiondefaults = sessiondefaults
+ self.authmode = authmode
}
private enum CodingKeys: String, CodingKey {
case presence
@@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable {
case configpath = "configPath"
case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
+ case authmode = "authMode"
}
}
@@ -432,7 +436,11 @@ public struct PollParams: Codable, Sendable {
public let question: String
public let options: [String]
public let maxselections: Int?
+ public let durationseconds: Int?
public let durationhours: Int?
+ public let silent: Bool?
+ public let isanonymous: Bool?
+ public let threadid: String?
public let channel: String?
public let accountid: String?
public let idempotencykey: String
@@ -442,7 +450,11 @@ public struct PollParams: Codable, Sendable {
question: String,
options: [String],
maxselections: Int?,
+ durationseconds: Int?,
durationhours: Int?,
+ silent: Bool?,
+ isanonymous: Bool?,
+ threadid: String?,
channel: String?,
accountid: String?,
idempotencykey: String
@@ -451,7 +463,11 @@ public struct PollParams: Codable, Sendable {
self.question = question
self.options = options
self.maxselections = maxselections
+ self.durationseconds = durationseconds
self.durationhours = durationhours
+ self.silent = silent
+ self.isanonymous = isanonymous
+ self.threadid = threadid
self.channel = channel
self.accountid = accountid
self.idempotencykey = idempotencykey
@@ -461,7 +477,11 @@ public struct PollParams: Codable, Sendable {
case question
case options
case maxselections = "maxSelections"
+ case durationseconds = "durationSeconds"
case durationhours = "durationHours"
+ case silent
+ case isanonymous = "isAnonymous"
+ case threadid = "threadId"
case channel
case accountid = "accountId"
case idempotencykey = "idempotencyKey"
@@ -1022,6 +1042,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawndepth: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1039,6 +1060,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawndepth: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?
) {
@@ -1055,6 +1077,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawndepth = spawndepth
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1072,6 +1095,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawndepth = "spawnDepth"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -1079,14 +1103,18 @@ public struct SessionsPatchParams: Codable, Sendable {
public struct SessionsResetParams: Codable, Sendable {
public let key: String
+ public let reason: AnyCodable?
public init(
- key: String
+ key: String,
+ reason: AnyCodable?
) {
self.key = key
+ self.reason = reason
}
private enum CodingKeys: String, CodingKey {
case key
+ case reason
}
}
@@ -2056,6 +2084,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
public struct CronJob: Codable, Sendable {
public let id: String
public let agentid: String?
+ public let sessionkey: String?
public let name: String
public let description: String?
public let enabled: Bool
@@ -2066,12 +2095,13 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
- public let delivery: [String: AnyCodable]?
+ public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
id: String,
agentid: String?,
+ sessionkey: String?,
name: String,
description: String?,
enabled: Bool,
@@ -2082,11 +2112,12 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
- delivery: [String: AnyCodable]?,
+ delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
self.agentid = agentid
+ self.sessionkey = sessionkey
self.name = name
self.description = description
self.enabled = enabled
@@ -2103,6 +2134,7 @@ public struct CronJob: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case agentid = "agentId"
+ case sessionkey = "sessionKey"
case name
case description
case enabled
@@ -2137,6 +2169,7 @@ public struct CronStatusParams: Codable, Sendable {
public struct CronAddParams: Codable, Sendable {
public let name: String
public let agentid: AnyCodable?
+ public let sessionkey: AnyCodable?
public let description: String?
public let enabled: Bool?
public let deleteafterrun: Bool?
@@ -2144,11 +2177,12 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
- public let delivery: [String: AnyCodable]?
+ public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
+ sessionkey: AnyCodable?,
description: String?,
enabled: Bool?,
deleteafterrun: Bool?,
@@ -2156,10 +2190,11 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
- delivery: [String: AnyCodable]?
+ delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
+ self.sessionkey = sessionkey
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
@@ -2172,6 +2207,7 @@ public struct CronAddParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case name
case agentid = "agentId"
+ case sessionkey = "sessionKey"
case description
case enabled
case deleteafterrun = "deleteAfterRun"
@@ -2380,6 +2416,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
+ public let twophase: Bool?
public init(
id: String?,
@@ -2391,7 +2428,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
- timeoutms: Int?
+ timeoutms: Int?,
+ twophase: Bool?
) {
self.id = id
self.command = command
@@ -2403,6 +2441,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.timeoutms = timeoutms
+ self.twophase = twophase
}
private enum CodingKeys: String, CodingKey {
case id
@@ -2415,6 +2454,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case timeoutms = "timeoutMs"
+ case twophase = "twoPhase"
}
}
@@ -2725,6 +2765,144 @@ public struct ChatEvent: Codable, Sendable {
}
}
+public struct MeshPlanParams: Codable, Sendable {
+ public let goal: String
+ public let steps: [[String: AnyCodable]]?
+
+ public init(
+ goal: String,
+ steps: [[String: AnyCodable]]?
+ ) {
+ self.goal = goal
+ self.steps = steps
+ }
+ private enum CodingKeys: String, CodingKey {
+ case goal
+ case steps
+ }
+}
+
+public struct MeshPlanAutoParams: Codable, Sendable {
+ public let goal: String
+ public let maxsteps: Int?
+ public let agentid: String?
+ public let sessionkey: String?
+ public let thinking: String?
+ public let timeoutms: Int?
+ public let lane: String?
+
+ public init(
+ goal: String,
+ maxsteps: Int?,
+ agentid: String?,
+ sessionkey: String?,
+ thinking: String?,
+ timeoutms: Int?,
+ lane: String?
+ ) {
+ self.goal = goal
+ self.maxsteps = maxsteps
+ self.agentid = agentid
+ self.sessionkey = sessionkey
+ self.thinking = thinking
+ self.timeoutms = timeoutms
+ self.lane = lane
+ }
+ private enum CodingKeys: String, CodingKey {
+ case goal
+ case maxsteps = "maxSteps"
+ case agentid = "agentId"
+ case sessionkey = "sessionKey"
+ case thinking
+ case timeoutms = "timeoutMs"
+ case lane
+ }
+}
+
+public struct MeshWorkflowPlan: Codable, Sendable {
+ public let planid: String
+ public let goal: String
+ public let createdat: Int
+ public let steps: [[String: AnyCodable]]
+
+ public init(
+ planid: String,
+ goal: String,
+ createdat: Int,
+ steps: [[String: AnyCodable]]
+ ) {
+ self.planid = planid
+ self.goal = goal
+ self.createdat = createdat
+ self.steps = steps
+ }
+ private enum CodingKeys: String, CodingKey {
+ case planid = "planId"
+ case goal
+ case createdat = "createdAt"
+ case steps
+ }
+}
+
+public struct MeshRunParams: Codable, Sendable {
+ public let plan: MeshWorkflowPlan
+ public let continueonerror: Bool?
+ public let maxparallel: Int?
+ public let defaultsteptimeoutms: Int?
+ public let lane: String?
+
+ public init(
+ plan: MeshWorkflowPlan,
+ continueonerror: Bool?,
+ maxparallel: Int?,
+ defaultsteptimeoutms: Int?,
+ lane: String?
+ ) {
+ self.plan = plan
+ self.continueonerror = continueonerror
+ self.maxparallel = maxparallel
+ self.defaultsteptimeoutms = defaultsteptimeoutms
+ self.lane = lane
+ }
+ private enum CodingKeys: String, CodingKey {
+ case plan
+ case continueonerror = "continueOnError"
+ case maxparallel = "maxParallel"
+ case defaultsteptimeoutms = "defaultStepTimeoutMs"
+ case lane
+ }
+}
+
+public struct MeshStatusParams: Codable, Sendable {
+ public let runid: String
+
+ public init(
+ runid: String
+ ) {
+ self.runid = runid
+ }
+ private enum CodingKeys: String, CodingKey {
+ case runid = "runId"
+ }
+}
+
+public struct MeshRetryParams: Codable, Sendable {
+ public let runid: String
+ public let stepids: [String]?
+
+ public init(
+ runid: String,
+ stepids: [String]?
+ ) {
+ self.runid = runid
+ self.stepids = stepids
+ }
+ private enum CodingKeys: String, CodingKey {
+ case runid = "runId"
+ case stepids = "stepIds"
+ }
+}
+
public struct UpdateRunParams: Codable, Sendable {
public let sessionkey: String?
public let note: String?
diff --git a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift
new file mode 100644
index 00000000000..ee537f1b62a
--- /dev/null
+++ b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift
@@ -0,0 +1,77 @@
+import OpenClawKit
+import Testing
+@testable import OpenClaw
+
+@Suite struct DeepLinkAgentPolicyTests {
+ @Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() {
+ let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
+ let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false)
+ switch res {
+ case let .failure(error):
+ #expect(
+ error == .messageTooLongForConfirmation(
+ max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars,
+ actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1))
+ case .success:
+ Issue.record("expected failure, got success")
+ }
+ }
+
+ @Test func validateMessageForHandleAllowsTooLongWhenKeyed() {
+ let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)
+ let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true)
+ switch res {
+ case .success:
+ break
+ case let .failure(error):
+ Issue.record("expected success, got failure: \(error)")
+ }
+ }
+
+ @Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() {
+ let link = AgentDeepLink(
+ message: "Hello",
+ sessionKey: "s",
+ thinking: "low",
+ deliver: true,
+ to: "+15551234567",
+ channel: "whatsapp",
+ timeoutSeconds: 10,
+ key: nil)
+ let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false)
+ #expect(res.deliver == false)
+ #expect(res.to == nil)
+ #expect(res.channel == .last)
+ }
+
+ @Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() {
+ let link = AgentDeepLink(
+ message: "Hello",
+ sessionKey: "s",
+ thinking: "low",
+ deliver: true,
+ to: " +15551234567 ",
+ channel: "whatsapp",
+ timeoutSeconds: 10,
+ key: "secret")
+ let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
+ #expect(res.deliver == true)
+ #expect(res.to == "+15551234567")
+ #expect(res.channel == .whatsapp)
+ }
+
+ @Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() {
+ let link = AgentDeepLink(
+ message: "Hello",
+ sessionKey: "s",
+ thinking: "low",
+ deliver: true,
+ to: "+15551234567",
+ channel: "webchat",
+ timeoutSeconds: 10,
+ key: "secret")
+ let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true)
+ #expect(res.deliver == false)
+ #expect(res.channel == .webchat)
+ }
+}
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
index 8ab50b6535f..44c464c449f 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
@@ -176,6 +176,48 @@ import Testing
#expect(host == "192.168.1.10")
}
+ @Test func dashboardURLUsesLocalBasePathInLocalMode() throws {
+ let config: GatewayConnection.Config = (
+ url: try #require(URL(string: "ws://127.0.0.1:18789")),
+ token: nil,
+ password: nil
+ )
+
+ let url = try GatewayEndpointStore.dashboardURL(
+ for: config,
+ mode: .local,
+ localBasePath: " control ")
+ #expect(url.absoluteString == "http://127.0.0.1:18789/control/")
+ }
+
+ @Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws {
+ let config: GatewayConnection.Config = (
+ url: try #require(URL(string: "ws://gateway.example:18789")),
+ token: nil,
+ password: nil
+ )
+
+ let url = try GatewayEndpointStore.dashboardURL(
+ for: config,
+ mode: .remote,
+ localBasePath: "/local-ui")
+ #expect(url.absoluteString == "http://gateway.example:18789/")
+ }
+
+ @Test func dashboardURLPrefersPathFromConfigURL() throws {
+ let config: GatewayConnection.Config = (
+ url: try #require(URL(string: "wss://gateway.example:443/remote-ui")),
+ token: nil,
+ password: nil
+ )
+
+ let url = try GatewayEndpointStore.dashboardURL(
+ for: config,
+ mode: .remote,
+ localBasePath: "/local-ui")
+ #expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
+ }
+
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift
index 046e47886c2..661382dda69 100644
--- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift
@@ -12,7 +12,8 @@ import Testing
uptimems: 123,
configpath: nil,
statedir: nil,
- sessiondefaults: nil)
+ sessiondefaults: nil,
+ authmode: nil)
let hello = HelloOk(
type: "hello",
diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
index c03505e2f4c..98e4e8046d3 100644
--- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
@@ -76,4 +76,43 @@ struct OpenClawConfigFileTests {
#expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json")
}
}
+
+ @MainActor
+ @Test
+ func saveDictAppendsConfigAuditLog() async throws {
+ let stateDir = FileManager().temporaryDirectory
+ .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
+ let configPath = stateDir.appendingPathComponent("openclaw.json")
+ let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
+
+ defer { try? FileManager().removeItem(at: stateDir) }
+
+ try await TestIsolation.withEnvValues([
+ "OPENCLAW_STATE_DIR": stateDir.path,
+ "OPENCLAW_CONFIG_PATH": configPath.path,
+ ]) {
+ OpenClawConfigFile.saveDict([
+ "gateway": ["mode": "local"],
+ ])
+
+ let configData = try Data(contentsOf: configPath)
+ let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
+ #expect((configRoot?["meta"] as? [String: Any]) != nil)
+
+ let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
+ let lines = rawAudit
+ .split(whereSeparator: \.isNewline)
+ .map(String.init)
+ #expect(!lines.isEmpty)
+ guard let last = lines.last else {
+ Issue.record("Missing config audit line")
+ return
+ }
+ let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
+ #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
+ #expect(auditRoot?["event"] as? String == "config.write")
+ #expect(auditRoot?["result"] as? String == "success")
+ #expect(auditRoot?["configPath"] as? String == configPath.path)
+ }
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
index 272fd81c11d..4dc8b9d8b14 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
@@ -103,18 +103,22 @@ public final class OpenClawChatViewModel {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
- var seen = Set()
- var recent: [OpenClawChatSessionEntry] = []
- for entry in sorted {
- guard !seen.contains(entry.key) else { continue }
- seen.insert(entry.key)
- guard (entry.updatedAt ?? 0) >= cutoff else { continue }
- recent.append(entry)
- }
var result: [OpenClawChatSessionEntry] = []
var included = Set()
- for entry in recent where !included.contains(entry.key) {
+
+ // Always show the main session first, even if it hasn't been updated recently.
+ if let main = sorted.first(where: { $0.key == "main" }) {
+ result.append(main)
+ included.insert(main.key)
+ } else {
+ result.append(self.placeholderSession(key: "main"))
+ included.insert("main")
+ }
+
+ for entry in sorted {
+ guard !included.contains(entry.key) else { continue }
+ guard (entry.updatedAt ?? 0) >= cutoff else { continue }
result.append(entry)
included.insert(entry.key)
}
@@ -166,7 +170,9 @@ public final class OpenClawChatViewModel {
}
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
- self.messages = Self.decodeMessages(payload.messages ?? [])
+ self.messages = Self.reconcileMessageIDs(
+ previous: self.messages,
+ incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
@@ -187,6 +193,70 @@ public final class OpenClawChatViewModel {
return Self.dedupeMessages(decoded)
}
+ private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
+ let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !role.isEmpty else { return nil }
+
+ let timestamp: String = {
+ guard let value = message.timestamp, value.isFinite else { return "" }
+ return String(format: "%.3f", value)
+ }()
+
+ let contentFingerprint = message.content.map { item in
+ let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
+ }.joined(separator: "\\u{001E}")
+
+ let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
+ return nil
+ }
+ return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
+ }
+
+ private static func reconcileMessageIDs(
+ previous: [OpenClawChatMessage],
+ incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
+ {
+ guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
+
+ var idsByKey: [String: [UUID]] = [:]
+ for message in previous {
+ guard let key = Self.messageIdentityKey(for: message) else { continue }
+ idsByKey[key, default: []].append(message.id)
+ }
+
+ return incoming.map { message in
+ guard let key = Self.messageIdentityKey(for: message),
+ var ids = idsByKey[key],
+ let reusedId = ids.first
+ else {
+ return message
+ }
+ ids.removeFirst()
+ if ids.isEmpty {
+ idsByKey.removeValue(forKey: key)
+ } else {
+ idsByKey[key] = ids
+ }
+ guard reusedId != message.id else { return message }
+ return OpenClawChatMessage(
+ id: reusedId,
+ role: message.role,
+ content: message.content,
+ timestamp: message.timestamp,
+ toolCallId: message.toolCallId,
+ toolName: message.toolName,
+ usage: message.usage,
+ stopReason: message.stopReason)
+ }
+ }
+
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
var result: [OpenClawChatMessage] = []
result.reserveCapacity(messages.count)
@@ -371,11 +441,15 @@ public final class OpenClawChatViewModel {
}
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
- if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
+ let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
+
+ // Gateway may publish canonical session keys (for example "agent:main:main")
+ // even when this view currently uses an alias key (for example "main").
+ // Never drop events for our own pending run on key mismatch, or the UI can stay
+ // stuck at "thinking" until the user reopens and forces a history reload.
+ if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey, !isOurRun {
return
}
-
- let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
if !isOurRun {
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
switch chat.state {
@@ -440,7 +514,9 @@ public final class OpenClawChatViewModel {
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
- self.messages = Self.decodeMessages(payload.messages ?? [])
+ self.messages = Self.reconcileMessageIDs(
+ previous: self.messages,
+ incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
index ef522447f43..02b53e3c392 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
@@ -1,93 +1,4 @@
-import Foundation
+import OpenClawProtocol
-/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
-///
-/// Marked `@unchecked Sendable` because it can hold reference types.
-public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
- public let value: Any
+public typealias AnyCodable = OpenClawProtocol.AnyCodable
- public init(_ value: Any) { self.value = value }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
- if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
- if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
- if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
- if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
- if container.decodeNil() { self.value = NSNull(); return }
- if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
- if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
- throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.singleValueContainer()
- switch self.value {
- case let intVal as Int: try container.encode(intVal)
- case let doubleVal as Double: try container.encode(doubleVal)
- case let boolVal as Bool: try container.encode(boolVal)
- case let stringVal as String: try container.encode(stringVal)
- case is NSNull: try container.encodeNil()
- case let dict as [String: AnyCodable]: try container.encode(dict)
- case let array as [AnyCodable]: try container.encode(array)
- case let dict as [String: Any]:
- try container.encode(dict.mapValues { AnyCodable($0) })
- case let array as [Any]:
- try container.encode(array.map { AnyCodable($0) })
- case let dict as NSDictionary:
- var converted: [String: AnyCodable] = [:]
- for (k, v) in dict {
- guard let key = k as? String else { continue }
- converted[key] = AnyCodable(v)
- }
- try container.encode(converted)
- case let array as NSArray:
- try container.encode(array.map { AnyCodable($0) })
- default:
- let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
- throw EncodingError.invalidValue(self.value, context)
- }
- }
-
- public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
- switch (lhs.value, rhs.value) {
- case let (l as Int, r as Int): l == r
- case let (l as Double, r as Double): l == r
- case let (l as Bool, r as Bool): l == r
- case let (l as String, r as String): l == r
- case (_ as NSNull, _ as NSNull): true
- case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
- case let (l as [AnyCodable], r as [AnyCodable]): l == r
- default:
- false
- }
- }
-
- public func hash(into hasher: inout Hasher) {
- switch self.value {
- case let v as Int:
- hasher.combine(0); hasher.combine(v)
- case let v as Double:
- hasher.combine(1); hasher.combine(v)
- case let v as Bool:
- hasher.combine(2); hasher.combine(v)
- case let v as String:
- hasher.combine(3); hasher.combine(v)
- case _ as NSNull:
- hasher.combine(4)
- case let v as [String: AnyCodable]:
- hasher.combine(5)
- for (k, val) in v.sorted(by: { $0.key < $1.key }) {
- hasher.combine(k)
- hasher.combine(val)
- }
- case let v as [AnyCodable]:
- hasher.combine(6)
- for item in v {
- hasher.combine(item)
- }
- default:
- hasher.combine(999)
- }
- }
-}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
index 10dd7ea0536..30606ca2671 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
@@ -2,6 +2,56 @@ import Foundation
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
+ case gateway(GatewayConnectDeepLink)
+}
+
+public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
+ public let host: String
+ public let port: Int
+ public let tls: Bool
+ public let token: String?
+ public let password: String?
+
+ public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
+ self.host = host
+ self.port = port
+ self.tls = tls
+ self.token = token
+ self.password = password
+ }
+
+ public var websocketURL: URL? {
+ let scheme = self.tls ? "wss" : "ws"
+ return URL(string: "\(scheme)://\(self.host):\(self.port)")
+ }
+
+ /// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
+ public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
+ guard let data = Self.decodeBase64Url(code) else { return nil }
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
+ guard let urlString = json["url"] as? String,
+ let parsed = URLComponents(string: urlString),
+ let hostname = parsed.host, !hostname.isEmpty
+ else { return nil }
+
+ let scheme = (parsed.scheme ?? "ws").lowercased()
+ let tls = scheme == "wss"
+ let port = parsed.port ?? (tls ? 443 : 18789)
+ let token = json["token"] as? String
+ let password = json["password"] as? String
+ return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
+ }
+
+ private static func decodeBase64Url(_ input: String) -> Data? {
+ var base64 = input
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let remainder = base64.count % 4
+ if remainder > 0 {
+ base64.append(contentsOf: String(repeating: "=", count: 4 - remainder))
+ }
+ return Data(base64Encoded: base64)
+ }
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
@@ -69,6 +119,23 @@ public enum DeepLinkParser {
channel: query["channel"],
timeoutSeconds: timeoutSeconds,
key: query["key"]))
+
+ case "gateway":
+ guard let hostParam = query["host"],
+ !hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ else {
+ return nil
+ }
+ let port = query["port"].flatMap { Int($0) } ?? 18789
+ let tls = (query["tls"] as NSString?)?.boolValue ?? false
+ return .gateway(
+ .init(
+ host: hostParam,
+ port: port,
+ tls: tls,
+ token: query["token"],
+ password: query["password"]))
+
default:
return nil
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
index a255fc7a81d..9682a31aa46 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
@@ -133,10 +133,16 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
- private let connectTimeoutSeconds: Double = 6
- private let connectChallengeTimeoutSeconds: Double = 3.0
+ // 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.
+ 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,
+ // but NATs/proxies often require outbound traffic to keep the connection alive.
+ private let keepaliveIntervalSeconds: Double = 15.0
private var watchdogTask: Task?
private var tickTask: Task?
+ private var keepaliveTask: Task?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -175,6 +181,9 @@ public actor GatewayChannelActor {
self.tickTask?.cancel()
self.tickTask = nil
+ self.keepaliveTask?.cancel()
+ self.keepaliveTask = nil
+
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
@@ -257,6 +266,7 @@ public actor GatewayChannelActor {
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
+ self.startKeepalive()
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
@@ -265,6 +275,29 @@ public actor GatewayChannelActor {
}
}
+ private func startKeepalive() {
+ self.keepaliveTask?.cancel()
+ self.keepaliveTask = Task { [weak self] in
+ guard let self else { return }
+ await self.keepaliveLoop()
+ }
+ }
+
+ private func keepaliveLoop() async {
+ while self.shouldReconnect {
+ try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000))
+ guard self.shouldReconnect else { return }
+ guard self.connected else { continue }
+ // Best-effort outbound message to keep intermediate NAT/proxy state alive.
+ // We intentionally ignore the response.
+ do {
+ try await self.send(method: "health", params: nil)
+ } catch {
+ // Avoid spamming logs; the reconnect paths will surface meaningful errors.
+ }
+ }
+ }
+
private func sendConnect() async throws {
let platform = InstanceIdentity.platformString
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
@@ -458,6 +491,8 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
+ self.keepaliveTask?.cancel()
+ self.keepaliveTask = nil
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped)
await self.scheduleReconnect()
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift
new file mode 100644
index 00000000000..e15baf17fdb
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Network
+
+public enum GatewayDiscoveryStatusText {
+ public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String {
+ if states.isEmpty {
+ return hasBrowsers ? "Setup" : "Idle"
+ }
+
+ if let failed = states.first(where: { state in
+ if case .failed = state { return true }
+ return false
+ }) {
+ if case let .failed(err) = failed {
+ return "Failed: \(err)"
+ }
+ }
+
+ if let waiting = states.first(where: { state in
+ if case .waiting = state { return true }
+ return false
+ }) {
+ if case let .waiting(err) = waiting {
+ return "Waiting: \(err)"
+ }
+ }
+
+ if states.contains(where: { if case .ready = $0 { true } else { false } }) {
+ return "Searching…"
+ }
+
+ if states.contains(where: { if case .setup = $0 { true } else { false } }) {
+ return "Setup"
+ }
+
+ return "Searching…"
+ }
+}
+
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
index 6311b4632cb..d0303f7e997 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
@@ -85,7 +85,13 @@ public actor GatewayNodeSession {
latch.resume(result)
}
timeoutTask = Task.detached {
- try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
+ do {
+ try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
+ } catch {
+ // Expected when invoke finishes first and cancels the timeout task.
+ return
+ }
+ guard !Task.isCancelled else { return }
timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)")
latch.resume(BridgeInvokeResponse(
id: request.id,
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift
index 8672ab09f68..139aa7d2942 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift
@@ -2,14 +2,6 @@ import OpenClawProtocol
import Foundation
public enum GatewayPayloadDecoding {
- public static func decode(
- _ payload: OpenClawProtocol.AnyCodable,
- as _: T.Type = T.self) throws -> T
- {
- let data = try JSONEncoder().encode(payload)
- return try JSONDecoder().decode(T.self, from: data)
- }
-
public static func decode(
_ payload: AnyCodable,
as _: T.Type = T.self) throws -> T
@@ -18,14 +10,6 @@ public enum GatewayPayloadDecoding {
return try JSONDecoder().decode(T.self, from: data)
}
- public static func decodeIfPresent(
- _ payload: OpenClawProtocol.AnyCodable?,
- as _: T.Type = T.self) throws -> T?
- {
- guard let payload else { return nil }
- return try self.decode(payload, as: T.self)
- }
-
public static func decodeIfPresent(
_ payload: AnyCodable?,
as _: T.Type = T.self) throws -> T?
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift
new file mode 100644
index 00000000000..3679ef54234
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift
@@ -0,0 +1,43 @@
+import Darwin
+import Foundation
+
+public enum NetworkInterfaces {
+ public static func primaryIPv4Address() -> String? {
+ var addrList: UnsafeMutablePointer?
+ guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
+ defer { freeifaddrs(addrList) }
+
+ var fallback: String?
+ var en0: String?
+
+ for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
+ let flags = Int32(ptr.pointee.ifa_flags)
+ let isUp = (flags & IFF_UP) != 0
+ let isLoopback = (flags & IFF_LOOPBACK) != 0
+ let name = String(cString: ptr.pointee.ifa_name)
+ let family = ptr.pointee.ifa_addr.pointee.sa_family
+ if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
+
+ var addr = ptr.pointee.ifa_addr.pointee
+ var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
+ let result = getnameinfo(
+ &addr,
+ socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
+ &buffer,
+ socklen_t(buffer.count),
+ nil,
+ 0,
+ NI_NUMERICHOST)
+ guard result == 0 else { continue }
+ let len = buffer.prefix { $0 != 0 }
+ let bytes = len.map { UInt8(bitPattern: $0) }
+ guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
+
+ if name == "en0" { en0 = ip; break }
+ if fallback == nil { fallback = ip }
+ }
+
+ return en0 ?? fallback
+ }
+}
+
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift
index b19792ad7b8..5af33d1d35c 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift
@@ -52,18 +52,26 @@ public enum OpenClawKitResources {
for candidate in candidates {
guard let baseURL = candidate else { continue }
- // Direct path
- let directURL = baseURL.appendingPathComponent("\(bundleName).bundle")
- if let bundle = Bundle(url: directURL) {
- return bundle
+ // SwiftPM often places the resource bundle next to (or near) the test runner bundle,
+ // not inside it. Walk up a few levels and check common container paths.
+ var roots: [URL] = []
+ roots.append(baseURL)
+ roots.append(baseURL.appendingPathComponent("Resources"))
+ roots.append(baseURL.appendingPathComponent("Contents/Resources"))
+
+ var current = baseURL
+ for _ in 0 ..< 5 {
+ current = current.deletingLastPathComponent()
+ roots.append(current)
+ roots.append(current.appendingPathComponent("Resources"))
+ roots.append(current.appendingPathComponent("Contents/Resources"))
}
- // Inside Resources/
- let resourcesURL = baseURL
- .appendingPathComponent("Resources")
- .appendingPathComponent("\(bundleName).bundle")
- if let bundle = Bundle(url: resourcesURL) {
- return bundle
+ for root in roots {
+ let bundleURL = root.appendingPathComponent("\(bundleName).bundle")
+ if let bundle = Bundle(url: bundleURL) {
+ return bundle
+ }
}
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift
new file mode 100644
index 00000000000..b5f00d34751
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+public enum PhotoCapture {
+ public static func transcodeJPEGForGateway(
+ rawData: Data,
+ maxWidthPx: Int,
+ quality: Double,
+ maxPayloadBytes: Int = 5 * 1024 * 1024
+ ) throws -> (data: Data, widthPx: Int, heightPx: Int) {
+ // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
+ let maxEncodedBytes = (maxPayloadBytes / 4) * 3
+ return try JPEGTranscoder.transcodeToJPEG(
+ imageData: rawData,
+ maxWidthPx: maxWidthPx,
+ quality: quality,
+ maxBytes: maxEncodedBytes)
+ }
+}
+
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift
index c63f40e9d3a..2a2e39d68cf 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift
@@ -1,10 +1,19 @@
public enum TalkPromptBuilder: Sendable {
- public static func build(transcript: String, interruptedAtSeconds: Double?) -> String {
+ public static func build(
+ transcript: String,
+ interruptedAtSeconds: Double?,
+ includeVoiceDirectiveHint: Bool = true
+ ) -> String {
var lines: [String] = [
"Talk Mode active. Reply in a concise, spoken tone.",
- "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.",
]
+ if includeVoiceDirectiveHint {
+ lines.append(
+ "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}."
+ )
+ }
+
if let interruptedAtSeconds {
let formatted = String(format: "%.1f", interruptedAtSeconds)
lines.append("Assistant speech interrupted at \(formatted)s.")
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift
index ad0c3387296..252e6131e4c 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift
@@ -1,8 +1,9 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
+///
/// Marked `@unchecked Sendable` because it can hold reference types.
-public struct AnyCodable: Codable, @unchecked Sendable {
+public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
public let value: Any
public init(_ value: Any) { self.value = value }
@@ -16,9 +17,7 @@ public struct AnyCodable: Codable, @unchecked Sendable {
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
- throw DecodingError.dataCorruptedError(
- in: container,
- debugDescription: "Unsupported type")
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
public func encode(to encoder: Encoder) throws {
@@ -51,4 +50,46 @@ public struct AnyCodable: Codable, @unchecked Sendable {
throw EncodingError.invalidValue(self.value, context)
}
}
+
+ public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
+ switch (lhs.value, rhs.value) {
+ case let (l as Int, r as Int): l == r
+ case let (l as Double, r as Double): l == r
+ case let (l as Bool, r as Bool): l == r
+ case let (l as String, r as String): l == r
+ case (_ as NSNull, _ as NSNull): true
+ case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
+ case let (l as [AnyCodable], r as [AnyCodable]): l == r
+ default:
+ false
+ }
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ switch self.value {
+ case let v as Int:
+ hasher.combine(0); hasher.combine(v)
+ case let v as Double:
+ hasher.combine(1); hasher.combine(v)
+ case let v as Bool:
+ hasher.combine(2); hasher.combine(v)
+ case let v as String:
+ hasher.combine(3); hasher.combine(v)
+ case _ as NSNull:
+ hasher.combine(4)
+ case let v as [String: AnyCodable]:
+ hasher.combine(5)
+ for (k, val) in v.sorted(by: { $0.key < $1.key }) {
+ hasher.combine(k)
+ hasher.combine(val)
+ }
+ case let v as [AnyCodable]:
+ hasher.combine(6)
+ for item in v {
+ hasher.combine(item)
+ }
+ default:
+ hasher.combine(999)
+ }
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index fca8eac3a93..13ea8ecc15e 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -295,6 +295,7 @@ public struct Snapshot: Codable, Sendable {
public let configpath: String?
public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
+ public let authmode: AnyCodable?
public init(
presence: [PresenceEntry],
@@ -303,7 +304,8 @@ public struct Snapshot: Codable, Sendable {
uptimems: Int,
configpath: String?,
statedir: String?,
- sessiondefaults: [String: AnyCodable]?
+ sessiondefaults: [String: AnyCodable]?,
+ authmode: AnyCodable?
) {
self.presence = presence
self.health = health
@@ -312,6 +314,7 @@ public struct Snapshot: Codable, Sendable {
self.configpath = configpath
self.statedir = statedir
self.sessiondefaults = sessiondefaults
+ self.authmode = authmode
}
private enum CodingKeys: String, CodingKey {
case presence
@@ -321,6 +324,7 @@ public struct Snapshot: Codable, Sendable {
case configpath = "configPath"
case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
+ case authmode = "authMode"
}
}
@@ -432,7 +436,11 @@ public struct PollParams: Codable, Sendable {
public let question: String
public let options: [String]
public let maxselections: Int?
+ public let durationseconds: Int?
public let durationhours: Int?
+ public let silent: Bool?
+ public let isanonymous: Bool?
+ public let threadid: String?
public let channel: String?
public let accountid: String?
public let idempotencykey: String
@@ -442,7 +450,11 @@ public struct PollParams: Codable, Sendable {
question: String,
options: [String],
maxselections: Int?,
+ durationseconds: Int?,
durationhours: Int?,
+ silent: Bool?,
+ isanonymous: Bool?,
+ threadid: String?,
channel: String?,
accountid: String?,
idempotencykey: String
@@ -451,7 +463,11 @@ public struct PollParams: Codable, Sendable {
self.question = question
self.options = options
self.maxselections = maxselections
+ self.durationseconds = durationseconds
self.durationhours = durationhours
+ self.silent = silent
+ self.isanonymous = isanonymous
+ self.threadid = threadid
self.channel = channel
self.accountid = accountid
self.idempotencykey = idempotencykey
@@ -461,7 +477,11 @@ public struct PollParams: Codable, Sendable {
case question
case options
case maxselections = "maxSelections"
+ case durationseconds = "durationSeconds"
case durationhours = "durationHours"
+ case silent
+ case isanonymous = "isAnonymous"
+ case threadid = "threadId"
case channel
case accountid = "accountId"
case idempotencykey = "idempotencyKey"
@@ -1022,6 +1042,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawndepth: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1039,6 +1060,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawndepth: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?
) {
@@ -1055,6 +1077,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawndepth = spawndepth
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1072,6 +1095,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawndepth = "spawnDepth"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -1079,14 +1103,18 @@ public struct SessionsPatchParams: Codable, Sendable {
public struct SessionsResetParams: Codable, Sendable {
public let key: String
+ public let reason: AnyCodable?
public init(
- key: String
+ key: String,
+ reason: AnyCodable?
) {
self.key = key
+ self.reason = reason
}
private enum CodingKeys: String, CodingKey {
case key
+ case reason
}
}
@@ -2056,6 +2084,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
public struct CronJob: Codable, Sendable {
public let id: String
public let agentid: String?
+ public let sessionkey: String?
public let name: String
public let description: String?
public let enabled: Bool
@@ -2066,12 +2095,13 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
- public let delivery: [String: AnyCodable]?
+ public let delivery: AnyCodable?
public let state: [String: AnyCodable]
public init(
id: String,
agentid: String?,
+ sessionkey: String?,
name: String,
description: String?,
enabled: Bool,
@@ -2082,11 +2112,12 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
- delivery: [String: AnyCodable]?,
+ delivery: AnyCodable?,
state: [String: AnyCodable]
) {
self.id = id
self.agentid = agentid
+ self.sessionkey = sessionkey
self.name = name
self.description = description
self.enabled = enabled
@@ -2103,6 +2134,7 @@ public struct CronJob: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case agentid = "agentId"
+ case sessionkey = "sessionKey"
case name
case description
case enabled
@@ -2137,6 +2169,7 @@ public struct CronStatusParams: Codable, Sendable {
public struct CronAddParams: Codable, Sendable {
public let name: String
public let agentid: AnyCodable?
+ public let sessionkey: AnyCodable?
public let description: String?
public let enabled: Bool?
public let deleteafterrun: Bool?
@@ -2144,11 +2177,12 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
- public let delivery: [String: AnyCodable]?
+ public let delivery: AnyCodable?
public init(
name: String,
agentid: AnyCodable?,
+ sessionkey: AnyCodable?,
description: String?,
enabled: Bool?,
deleteafterrun: Bool?,
@@ -2156,10 +2190,11 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
- delivery: [String: AnyCodable]?
+ delivery: AnyCodable?
) {
self.name = name
self.agentid = agentid
+ self.sessionkey = sessionkey
self.description = description
self.enabled = enabled
self.deleteafterrun = deleteafterrun
@@ -2172,6 +2207,7 @@ public struct CronAddParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case name
case agentid = "agentId"
+ case sessionkey = "sessionKey"
case description
case enabled
case deleteafterrun = "deleteAfterRun"
@@ -2380,6 +2416,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let resolvedpath: AnyCodable?
public let sessionkey: AnyCodable?
public let timeoutms: Int?
+ public let twophase: Bool?
public init(
id: String?,
@@ -2391,7 +2428,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
agentid: AnyCodable?,
resolvedpath: AnyCodable?,
sessionkey: AnyCodable?,
- timeoutms: Int?
+ timeoutms: Int?,
+ twophase: Bool?
) {
self.id = id
self.command = command
@@ -2403,6 +2441,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.timeoutms = timeoutms
+ self.twophase = twophase
}
private enum CodingKeys: String, CodingKey {
case id
@@ -2415,6 +2454,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case timeoutms = "timeoutMs"
+ case twophase = "twoPhase"
}
}
@@ -2725,6 +2765,144 @@ public struct ChatEvent: Codable, Sendable {
}
}
+public struct MeshPlanParams: Codable, Sendable {
+ public let goal: String
+ public let steps: [[String: AnyCodable]]?
+
+ public init(
+ goal: String,
+ steps: [[String: AnyCodable]]?
+ ) {
+ self.goal = goal
+ self.steps = steps
+ }
+ private enum CodingKeys: String, CodingKey {
+ case goal
+ case steps
+ }
+}
+
+public struct MeshPlanAutoParams: Codable, Sendable {
+ public let goal: String
+ public let maxsteps: Int?
+ public let agentid: String?
+ public let sessionkey: String?
+ public let thinking: String?
+ public let timeoutms: Int?
+ public let lane: String?
+
+ public init(
+ goal: String,
+ maxsteps: Int?,
+ agentid: String?,
+ sessionkey: String?,
+ thinking: String?,
+ timeoutms: Int?,
+ lane: String?
+ ) {
+ self.goal = goal
+ self.maxsteps = maxsteps
+ self.agentid = agentid
+ self.sessionkey = sessionkey
+ self.thinking = thinking
+ self.timeoutms = timeoutms
+ self.lane = lane
+ }
+ private enum CodingKeys: String, CodingKey {
+ case goal
+ case maxsteps = "maxSteps"
+ case agentid = "agentId"
+ case sessionkey = "sessionKey"
+ case thinking
+ case timeoutms = "timeoutMs"
+ case lane
+ }
+}
+
+public struct MeshWorkflowPlan: Codable, Sendable {
+ public let planid: String
+ public let goal: String
+ public let createdat: Int
+ public let steps: [[String: AnyCodable]]
+
+ public init(
+ planid: String,
+ goal: String,
+ createdat: Int,
+ steps: [[String: AnyCodable]]
+ ) {
+ self.planid = planid
+ self.goal = goal
+ self.createdat = createdat
+ self.steps = steps
+ }
+ private enum CodingKeys: String, CodingKey {
+ case planid = "planId"
+ case goal
+ case createdat = "createdAt"
+ case steps
+ }
+}
+
+public struct MeshRunParams: Codable, Sendable {
+ public let plan: MeshWorkflowPlan
+ public let continueonerror: Bool?
+ public let maxparallel: Int?
+ public let defaultsteptimeoutms: Int?
+ public let lane: String?
+
+ public init(
+ plan: MeshWorkflowPlan,
+ continueonerror: Bool?,
+ maxparallel: Int?,
+ defaultsteptimeoutms: Int?,
+ lane: String?
+ ) {
+ self.plan = plan
+ self.continueonerror = continueonerror
+ self.maxparallel = maxparallel
+ self.defaultsteptimeoutms = defaultsteptimeoutms
+ self.lane = lane
+ }
+ private enum CodingKeys: String, CodingKey {
+ case plan
+ case continueonerror = "continueOnError"
+ case maxparallel = "maxParallel"
+ case defaultsteptimeoutms = "defaultStepTimeoutMs"
+ case lane
+ }
+}
+
+public struct MeshStatusParams: Codable, Sendable {
+ public let runid: String
+
+ public init(
+ runid: String
+ ) {
+ self.runid = runid
+ }
+ private enum CodingKeys: String, CodingKey {
+ case runid = "runId"
+ }
+}
+
+public struct MeshRetryParams: Codable, Sendable {
+ public let runid: String
+ public let stepids: [String]?
+
+ public init(
+ runid: String,
+ stepids: [String]?
+ ) {
+ self.runid = runid
+ self.stepids = stepids
+ }
+ private enum CodingKeys: String, CodingKey {
+ case runid = "runId"
+ case stepids = "stepIds"
+ }
+}
+
public struct UpdateRunParams: Codable, Sendable {
public let sessionkey: String?
public let note: String?
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
index 3babe8b9a30..852ae0e7ff0 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
@@ -215,6 +215,103 @@ extension TestChatTransportState {
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
+ @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
+ let history1 = OpenClawChatHistoryPayload(
+ sessionKey: "main",
+ sessionId: "sess-main",
+ messages: [],
+ thinkingLevel: "off")
+ let history2 = OpenClawChatHistoryPayload(
+ sessionKey: "main",
+ sessionId: "sess-main",
+ messages: [
+ AnyCodable([
+ "role": "assistant",
+ "content": [["type": "text", "text": "from history"]],
+ "timestamp": Date().timeIntervalSince1970 * 1000,
+ ]),
+ ],
+ thinkingLevel: "off")
+
+ let transport = TestChatTransport(historyResponses: [history1, history2])
+ let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+
+ await MainActor.run { vm.load() }
+ try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
+
+ await MainActor.run {
+ vm.input = "hi"
+ vm.send()
+ }
+ try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
+
+ let runId = try #require(await transport.lastSentRunId())
+ transport.emit(
+ .chat(
+ OpenClawChatEventPayload(
+ runId: runId,
+ sessionKey: "agent:main:main",
+ state: "final",
+ message: nil,
+ errorMessage: nil)))
+
+ try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
+ try await waitUntil("history refresh") {
+ await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
+ }
+ }
+
+ @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
+ let now = Date().timeIntervalSince1970 * 1000
+ let history1 = OpenClawChatHistoryPayload(
+ sessionKey: "main",
+ sessionId: "sess-main",
+ messages: [
+ AnyCodable([
+ "role": "user",
+ "content": [["type": "text", "text": "hello"]],
+ "timestamp": now,
+ ]),
+ ],
+ thinkingLevel: "off")
+ let history2 = OpenClawChatHistoryPayload(
+ sessionKey: "main",
+ sessionId: "sess-main",
+ messages: [
+ AnyCodable([
+ "role": "user",
+ "content": [["type": "text", "text": "hello"]],
+ "timestamp": now,
+ ]),
+ AnyCodable([
+ "role": "assistant",
+ "content": [["type": "text", "text": "world"]],
+ "timestamp": now + 1,
+ ]),
+ ],
+ thinkingLevel: "off")
+
+ let transport = TestChatTransport(historyResponses: [history1, history2])
+ let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+
+ await MainActor.run { vm.load() }
+ try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
+ let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
+
+ transport.emit(
+ .chat(
+ OpenClawChatEventPayload(
+ runId: "other-run",
+ sessionKey: "main",
+ state: "final",
+ message: nil,
+ errorMessage: nil)))
+
+ try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
+ let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
+ #expect(firstIdAfter == firstIdBefore)
+ }
+
@Test func clearsStreamingOnExternalFinalEvent() async throws {
let sessionId = "sess-main"
let history = OpenClawChatHistoryPayload(
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift
index 1ca18fdf32d..513b60d047a 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift
@@ -12,4 +12,18 @@ final class TalkPromptBuilderTests: XCTestCase {
let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234)
XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s."))
}
+
+ func testBuildIncludesVoiceDirectiveHintByDefault() {
+ let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
+ XCTAssertTrue(prompt.contains("ElevenLabs voice"))
+ }
+
+ func testBuildExcludesVoiceDirectiveHintWhenDisabled() {
+ let prompt = TalkPromptBuilder.build(
+ transcript: "Hello",
+ interruptedAtSeconds: nil,
+ includeVoiceDirectiveHint: false)
+ XCTAssertFalse(prompt.contains("ElevenLabs voice"))
+ XCTAssertTrue(prompt.contains("Talk Mode active."))
+ }
}
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index b1e5ef9a10c..96fd46f99d5 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -27,6 +27,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- **Main session**: enqueue a system event, then run on the next heartbeat.
- **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
+- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`.
+- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
## Quick start (actionable)
@@ -99,7 +101,7 @@ A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
-- optional **delivery mode** (announce or none).
+- optional **delivery mode** (`announce`, `webhook`, or `none`).
- optional **agent binding** (`agentId`): run the job under a specific agent; if
missing or unknown, the gateway falls back to the default agent.
@@ -140,8 +142,9 @@ Key behaviors:
- Prompt is prefixed with `[cron: ]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
-- `delivery.mode` (isolated-only) chooses what happens:
+- `delivery.mode` chooses what happens:
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
+ - `webhook`: POST the finished event payload to `delivery.to` when the finished event includes a summary.
- `none`: internal only (no delivery, no main-session summary).
- `wakeMode` controls when the main-session summary posts:
- `now`: immediate heartbeat.
@@ -163,11 +166,11 @@ Common `agentTurn` fields:
- `model` / `thinking`: optional overrides (see below).
- `timeoutSeconds`: optional timeout override.
-Delivery config (isolated jobs only):
+Delivery config:
-- `delivery.mode`: `none` | `announce`.
+- `delivery.mode`: `none` | `announce` | `webhook`.
- `delivery.channel`: `last` or a specific channel.
-- `delivery.to`: channel-specific target (phone/chat/channel id).
+- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode).
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
@@ -192,6 +195,18 @@ Behavior details:
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
`next-heartbeat` waits for the next scheduled heartbeat.
+#### Webhook delivery flow
+
+When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to` when the finished event includes a summary.
+
+Behavior details:
+
+- The endpoint must be a valid HTTP(S) URL.
+- No channel delivery is attempted in webhook mode.
+- No main-session summary is posted in webhook mode.
+- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `.
+- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`.
+
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
@@ -213,11 +228,12 @@ Resolution priority:
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
-- `delivery.mode`: `announce` (deliver a summary) or `none`.
+- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`.
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
- `delivery.to`: channel-specific recipient target.
-Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
+`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`).
+`webhook` delivery is valid for both main and isolated jobs.
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s
“last route” (the last place the agent replied).
@@ -333,10 +349,21 @@ Notes:
enabled: true, // default true
store: "~/.openclaw/cron/jobs.json",
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
},
}
```
+Webhook behavior:
+
+- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job.
+- Webhook URLs must be valid `http://` or `https://` URLs.
+- When posted, payload is the cron finished event JSON.
+- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `.
+- If `cron.webhookToken` is not set, no `Authorization` header is sent.
+- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present.
+
Disable cron entirely:
- `cron.enabled: false` (config)
@@ -476,3 +503,10 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
- For forum topics, use `-100…:topic:` so it’s explicit and unambiguous.
- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal;
cron delivery accepts them and still parses topic IDs correctly.
+
+### Subagent announce delivery retries
+
+- When a subagent run completes, the gateway announces the result to the requester session.
+- If the announce flow returns `false` (e.g. requester session is busy), the gateway retries up to 3 times with tracking via `announceRetryCount`.
+- Announces older than 5 minutes past `endedAt` are force-expired to prevent stale entries from looping indefinitely.
+- If you see repeated announce deliveries in logs, check the subagent registry for entries with high `announceRetryCount` values.
diff --git a/docs/automation/gmail-pubsub.md b/docs/automation/gmail-pubsub.md
index 734ae6f7702..b853b995599 100644
--- a/docs/automation/gmail-pubsub.md
+++ b/docs/automation/gmail-pubsub.md
@@ -88,7 +88,7 @@ Notes:
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
-under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
+under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)).
## Wizard (recommended)
diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md
index 2030e9aeaf6..ffdf32ab79b 100644
--- a/docs/automation/hooks.md
+++ b/docs/automation/hooks.md
@@ -41,9 +41,10 @@ The hooks system allows you to:
### Bundled Hooks
-OpenClaw ships with three bundled hooks that are automatically discovered:
+OpenClaw ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new`
+- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap`
- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
@@ -102,6 +103,8 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw
openclaw hooks install
```
+Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected.
+
Example `package.json`:
```json
@@ -117,6 +120,10 @@ Example `package.json`:
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/`.
+Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts`
+(no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely
+on `postinstall` builds.
+
## Hook Structure
### HOOK.md Format
@@ -127,7 +134,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
---
name: my-hook
description: "Short description of what this hook does"
-homepage: https://docs.openclaw.ai/hooks#my-hook
+homepage: https://docs.openclaw.ai/automation/hooks#my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
---
@@ -393,6 +400,8 @@ The old config format still works for backwards compatibility:
}
```
+Note: `module` must be a workspace-relative path. Absolute paths and traversal outside the workspace are rejected.
+
**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks.
## CLI Commands
@@ -484,6 +493,47 @@ Saves session context to memory when you issue `/new`.
openclaw hooks enable session-memory
```
+### bootstrap-extra-files
+
+Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
+
+**Events**: `agent:bootstrap`
+
+**Requirements**: `workspace.dir` must be configured
+
+**Output**: No files written; bootstrap context is modified in-memory only.
+
+**Config**:
+
+```json
+{
+ "hooks": {
+ "internal": {
+ "enabled": true,
+ "entries": {
+ "bootstrap-extra-files": {
+ "enabled": true,
+ "paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
+ }
+ }
+ }
+ }
+}
+```
+
+**Notes**:
+
+- Paths are resolved relative to workspace.
+- Files must stay inside workspace (realpath-checked).
+- Only recognized bootstrap basenames are loaded.
+- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only).
+
+**Enable**:
+
+```bash
+openclaw hooks enable bootstrap-extra-files
+```
+
### command-logger
Logs all command events to a centralized audit file.
@@ -618,6 +668,7 @@ The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
+Registered hook: bootstrap-extra-files -> agent:bootstrap
Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
```
diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md
index 30556ee0c6a..8072b4a1a3f 100644
--- a/docs/automation/webhook.md
+++ b/docs/automation/webhook.md
@@ -140,6 +140,8 @@ Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
+ - `hooks.transformsDir` (if set) must stay within the transforms root under your OpenClaw config directory (typically `~/.openclaw/hooks/transforms`).
+ - `transform.module` must resolve within the effective transforms directory (traversal/escape paths are rejected).
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md
index ab852e98214..fd677a1d585 100644
--- a/docs/channels/bluebubbles.md
+++ b/docs/channels/bluebubbles.md
@@ -44,6 +44,10 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=`).
5. Start the gateway; it will register the webhook handler and start pairing.
+Security note:
+
+- Always set a webhook password. If you expose the gateway through a reverse proxy (Tailscale Serve/Funnel, nginx, Cloudflare Tunnel, ngrok), the proxy may connect to the gateway over loopback. The BlueBubbles webhook handler treats requests with forwarding headers as proxied and will not accept passwordless webhooks.
+
## Keeping Messages.app alive (VM / headless setups)
Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
@@ -300,6 +304,7 @@ Provider options:
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
+- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`.
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
- `channels.bluebubbles.actions`: Enable/disable specific actions.
diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md
index 6ee19453917..49c4a6120d6 100644
--- a/docs/channels/channel-routing.md
+++ b/docs/channels/channel-routing.md
@@ -44,11 +44,15 @@ Examples:
Routing picks **one agent** for each inbound message:
1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`).
-2. **Guild match** (Discord) via `guildId`.
-3. **Team match** (Slack) via `teamId`.
-4. **Account match** (`accountId` on the channel).
-5. **Channel match** (any account on that channel).
-6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
+2. **Parent peer match** (thread inheritance).
+3. **Guild + roles match** (Discord) via `guildId` + `roles`.
+4. **Guild match** (Discord) via `guildId`.
+5. **Team match** (Slack) via `teamId`.
+6. **Account match** (`accountId` on the channel).
+7. **Channel match** (any account on that channel, `accountId: "*"`).
+8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
+
+When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply.
The matched agent determines which workspace and session store are used.
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index c232a042ff2..05b8003e953 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -87,15 +87,95 @@ Token resolution is account-aware. Config token values win over env fallback. `D
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).
- Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session.
+## Interactive components
+
+OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings.
+
+Supported blocks:
+
+- `text`, `section`, `separator`, `actions`, `media-gallery`, `file`
+- Action rows allow up to 5 buttons or a single select menu
+- Select types: `string`, `user`, `role`, `mentionable`, `channel`
+
+By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire.
+
+To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
+
+File attachments:
+
+- `file` blocks must point to an attachment reference (`attachment://`)
+- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files
+- Use `filename` to override the upload name when it should match the attachment reference
+
+Modal forms:
+
+- Add `components.modal` with up to 5 fields
+- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select`
+- OpenClaw adds a trigger button automatically
+
+Example:
+
+```json5
+{
+ channel: "discord",
+ action: "send",
+ to: "channel:123456789012345678",
+ message: "Optional fallback text",
+ components: {
+ reusable: true,
+ text: "Choose a path",
+ blocks: [
+ {
+ type: "actions",
+ buttons: [
+ {
+ label: "Approve",
+ style: "success",
+ allowedUsers: ["123456789012345678"],
+ },
+ { label: "Decline", style: "danger" },
+ ],
+ },
+ {
+ type: "actions",
+ select: {
+ type: "string",
+ placeholder: "Pick an option",
+ options: [
+ { label: "Option A", value: "a" },
+ { label: "Option B", value: "b" },
+ ],
+ },
+ },
+ ],
+ modal: {
+ title: "Details",
+ triggerLabel: "Open form",
+ fields: [
+ { type: "text", label: "Requester" },
+ {
+ type: "select",
+ label: "Priority",
+ options: [
+ { label: "Low", value: "low" },
+ { label: "High", value: "high" },
+ ],
+ },
+ ],
+ },
+ },
+}
+```
+
## Access control and routing
- `channels.discord.dm.policy` controls DM access:
+ `channels.discord.dmPolicy` controls DM access (legacy: `channels.discord.dm.policy`):
- `pairing` (default)
- `allowlist`
- - `open` (requires `channels.discord.dm.allowFrom` to include `"*"`)
+ - `open` (requires `channels.discord.allowFrom` to include `"*"`; legacy: `channels.discord.dm.allowFrom`)
- `disabled`
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
@@ -173,7 +253,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
### Role-based agent routing
-Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings.
+Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match.
```json5
{
@@ -273,6 +353,8 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
- `first`
- `all`
+ Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
+
Message IDs are surfaced in context/history so agents can target specific messages.
@@ -311,6 +393,23 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
+
+ `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+ Resolution order:
+
+ - `channels.discord.accounts..ackReaction`
+ - `channels.discord.ackReaction`
+ - `messages.ackReaction`
+ - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+ Notes:
+
+ - Discord accepts unicode emoji or custom emoji names.
+ - Use `""` to disable the reaction for a channel or account.
+
+
+
Channel-initiated config writes are enabled by default.
@@ -330,6 +429,37 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
+
+ Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`.
+
+```json5
+{
+ channels: {
+ discord: {
+ proxy: "http://proxy.example:8080",
+ },
+ },
+}
+```
+
+ Per-account override:
+
+```json5
+{
+ channels: {
+ discord: {
+ accounts: {
+ primary: {
+ proxy: "http://proxy.example:8080",
+ },
+ },
+ },
+ },
+}
+```
+
+
+
Enable PluralKit resolution to map proxied messages to system member identity:
@@ -355,15 +485,71 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
+
+ Presence updates are applied only when you set a status or activity field.
+
+ Status only example:
+
+```json5
+{
+ channels: {
+ discord: {
+ status: "idle",
+ },
+ },
+}
+```
+
+ Activity example (custom status is the default activity type):
+
+```json5
+{
+ channels: {
+ discord: {
+ activity: "Focus time",
+ activityType: 4,
+ },
+ },
+}
+```
+
+ Streaming example:
+
+```json5
+{
+ channels: {
+ discord: {
+ activity: "Live coding",
+ activityType: 1,
+ activityUrl: "https://twitch.tv/openclaw",
+ },
+ },
+}
+```
+
+ Activity type map:
+
+ - 0: Playing
+ - 1: Streaming (requires `activityUrl`)
+ - 2: Listening
+ - 3: Watching
+ - 4: Custom (uses the activity text as the status state; emoji is optional)
+ - 5: Competing
+
+
+
- Discord supports button-based exec approvals in DMs.
+ Discord supports button-based exec approvals in DMs and can optionally post approval prompts in the originating channel.
Config path:
- `channels.discord.execApprovals.enabled`
- `channels.discord.execApprovals.approvers`
+ - `channels.discord.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`, `cleanupAfterResolve`
+ When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only configured approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
+
If approvals fail with unknown approval IDs, verify approver list and feature enablement.
Related docs: [Exec approvals](/tools/exec-approvals)
@@ -393,6 +579,46 @@ Default gate behavior:
| moderation | disabled |
| presence | disabled |
+## Components v2 UI
+
+OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended.
+
+- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex).
+- Set per account with `channels.discord.accounts..ui.components.accentColor`.
+- `embeds` are ignored when components v2 are present.
+
+Example:
+
+```json5
+{
+ channels: {
+ discord: {
+ ui: {
+ components: {
+ accentColor: "#5865F2",
+ },
+ },
+ },
+ },
+}
+```
+
+## Voice messages
+
+Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files.
+
+Requirements and constraints:
+
+- Provide a **local file path** (URLs are rejected).
+- Omit text content (Discord does not allow text + voice message in the same payload).
+- Any audio format is accepted; OpenClaw converts to OGG/Opus when needed.
+
+Example:
+
+```bash
+message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true)
+```
+
## Troubleshooting
@@ -440,7 +666,7 @@ openclaw logs --follow
- DM disabled: `channels.discord.dm.enabled=false`
- - DM policy disabled: `channels.discord.dm.policy="disabled"`
+ - DM policy disabled: `channels.discord.dmPolicy="disabled"` (legacy: `channels.discord.dm.policy`)
- awaiting pairing approval in `pairing` mode
@@ -468,6 +694,8 @@ High-signal Discord fields:
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
- media/retry: `mediaMaxMb`, `retry`
- actions: `actions.*`
+- presence: `activity`, `status`, `activityType`, `activityUrl`
+- UI: `ui.components.accentColor`
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
## Safety and operations
diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md
index 39192ecae2f..818a8288f5d 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/` or `users/` (email addresses are accepted).
+- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal).
+- Deprecated: `users/` is treated as a user id, not an email allowlist.
- Spaces: `spaces/`.
## Config highlights
diff --git a/docs/channels/grammy.md b/docs/channels/grammy.md
index c2891d1a2ee..ae92c5292b0 100644
--- a/docs/channels/grammy.md
+++ b/docs/channels/grammy.md
@@ -21,7 +21,7 @@ title: grammY
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
-- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
+- **Live stream preview:** optional `channels.telegram.streamMode` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
Open questions
diff --git a/docs/channels/groups.md b/docs/channels/groups.md
index d2497148b2c..6bd278846c5 100644
--- a/docs/channels/groups.md
+++ b/docs/channels/groups.md
@@ -105,7 +105,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w
docker: {
binds: [
// hostPath:containerPath:mode
- "~/FriendsShared:/data:ro",
+ "/home/user/FriendsShared:/data:ro",
],
},
},
@@ -138,7 +138,7 @@ Control how group/room messages are handled per channel:
},
telegram: {
groupPolicy: "disabled",
- groupAllowFrom: ["123456789", "@username"],
+ groupAllowFrom: ["123456789"], // numeric Telegram user id (wizard can resolve @username)
},
signal: {
groupPolicy: "disabled",
diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md
index 68a5ac50509..04205d94971 100644
--- a/docs/channels/matrix.md
+++ b/docs/channels/matrix.md
@@ -136,6 +136,47 @@ When E2EE is enabled, the bot will request verification from your other sessions
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
+## Multi-account
+
+Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
+
+Each account runs as a separate Matrix user on any homeserver. Per-account config
+inherits from the top-level `channels.matrix` settings and can override any option
+(DM policy, groups, encryption, etc.).
+
+```json5
+{
+ channels: {
+ matrix: {
+ enabled: true,
+ dm: { policy: "pairing" },
+ accounts: {
+ assistant: {
+ name: "Main assistant",
+ homeserver: "https://matrix.example.org",
+ accessToken: "syt_assistant_***",
+ encryption: true,
+ },
+ alerts: {
+ name: "Alerts bot",
+ homeserver: "https://matrix.example.org",
+ accessToken: "syt_alerts_***",
+ dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
+ },
+ },
+ },
+ },
+}
+```
+
+Notes:
+
+- Account startup is serialized to avoid race conditions with concurrent module imports.
+- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
+- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
+- Use `bindings[].match.accountId` to route each account to a different agent.
+- Crypto state is stored per account + access token (separate key stores per account).
+
## Routing model
- Replies always go back to Matrix.
@@ -149,6 +190,7 @@ Once verified, the bot can decrypt messages in encrypted rooms.
- `openclaw pairing approve matrix `
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match.
+- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs.
## Rooms (groups)
@@ -256,4 +298,5 @@ Provider options:
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
+- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md
index f4353180e2a..052a8cd6b12 100644
--- a/docs/channels/mattermost.md
+++ b/docs/channels/mattermost.md
@@ -70,6 +70,7 @@ Mattermost responds to DMs automatically. Channel behavior is controlled by `cha
- `oncall` (default): respond only when @mentioned in channels.
- `onmessage`: respond to every channel message.
+- `always`: respond to every message in channels (same channel behavior as `onmessage`).
- `onchar`: respond when a message starts with a trigger prefix.
Config example:
@@ -89,6 +90,25 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
+- Current limitation: due to Mattermost plugin event behavior (`#11797`), `chatmode: "onmessage"` and
+ `chatmode: "always"` may still require explicit group mention override to respond without @mentions.
+ Use:
+
+```json5
+{
+ channels: {
+ mattermost: {
+ groupPolicy: "open",
+ groups: {
+ "*": { requireMention: false },
+ },
+ },
+ },
+}
+```
+
+Reference: [Bug: Mattermost plugin does not receive channel message events via WebSocket #11797](https://github.com/open-webui/open-webui/issues/11797).
+Related fix scope: [fix(mattermost): honor chatmode mention fallback in group mention gating #14995](https://github.com/open-webui/open-webui/pull/14995).
## Access control (DMs)
@@ -133,6 +153,7 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`:
## Troubleshooting
-- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
+- No replies in channels: ensure the bot is in the channel and use the mode behavior correctly: mention it (`oncall`), use a trigger prefix (`onchar`), or use `onmessage`/`always` with:
+ `channels.mattermost.groups["*"].requireMention = false` (and typically `groupPolicy: "open"`).
- Auth errors: check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.
diff --git a/docs/channels/signal.md b/docs/channels/signal.md
index df4d630cc55..60bb5f7ce92 100644
--- a/docs/channels/signal.md
+++ b/docs/channels/signal.md
@@ -1,5 +1,5 @@
---
-summary: "Signal support via signal-cli (JSON-RPC + SSE), setup, and number model"
+summary: "Signal support via signal-cli (JSON-RPC + SSE), setup paths, and number model"
read_when:
- Setting up Signal support
- Debugging Signal send/receive
@@ -10,13 +10,22 @@ title: "Signal"
Status: external CLI integration. Gateway talks to `signal-cli` over HTTP JSON-RPC + SSE.
+## Prerequisites
+
+- OpenClaw installed on your server (Linux flow below tested on Ubuntu 24).
+- `signal-cli` available on the host where the gateway runs.
+- A phone number that can receive one verification SMS (for SMS registration path).
+- Browser access for Signal captcha (`signalcaptchas.org`) during registration.
+
## Quick setup (beginner)
1. Use a **separate Signal number** for the bot (recommended).
-2. Install `signal-cli` (Java required).
-3. Link the bot device and start the daemon:
- - `signal-cli link -n "OpenClaw"`
-4. Configure OpenClaw and start the gateway.
+2. Install `signal-cli` (Java required if you use the JVM build).
+3. Choose one setup path:
+ - **Path A (QR link):** `signal-cli link -n "OpenClaw"` and scan with Signal.
+ - **Path B (SMS register):** register a dedicated number with captcha + SMS verification.
+4. Configure OpenClaw and restart the gateway.
+5. Send a first DM and approve pairing (`openclaw pairing approve signal `).
Minimal config:
@@ -34,6 +43,15 @@ Minimal config:
}
```
+Field reference:
+
+| Field | Description |
+| ----------- | ------------------------------------------------- |
+| `account` | Bot phone number in E.164 format (`+15551234567`) |
+| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) |
+| `dmPolicy` | DM access policy (`pairing` recommended) |
+| `allowFrom` | Phone numbers or `uuid:` values allowed to DM |
+
## What it is
- Signal channel via `signal-cli` (not embedded libsignal).
@@ -58,9 +76,9 @@ Disable with:
- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection).
- For "I text the bot and it replies," use a **separate bot number**.
-## Setup (fast path)
+## Setup path A: link existing Signal account (QR)
-1. Install `signal-cli` (Java required).
+1. Install `signal-cli` (JVM or native build).
2. Link a bot account:
- `signal-cli link -n "OpenClaw"` then scan the QR in Signal.
3. Configure Signal and start the gateway.
@@ -83,6 +101,67 @@ Example:
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
+## Setup path B: register dedicated bot number (SMS, Linux)
+
+Use this when you want a dedicated bot number instead of linking an existing Signal app account.
+
+1. Get a number that can receive SMS (or voice verification for landlines).
+ - Use a dedicated bot number to avoid account/session conflicts.
+2. Install `signal-cli` on the gateway host:
+
+```bash
+VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
+curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz"
+sudo tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt
+sudo ln -sf /opt/signal-cli /usr/local/bin/
+signal-cli --version
+```
+
+If you use the JVM build (`signal-cli-${VERSION}.tar.gz`), install JRE 25+ first.
+Keep `signal-cli` updated; upstream notes that old releases can break as Signal server APIs change.
+
+3. Register and verify the number:
+
+```bash
+signal-cli -a + register
+```
+
+If captcha is required:
+
+1. Open `https://signalcaptchas.org/registration/generate.html`.
+2. Complete captcha, copy the `signalcaptcha://...` link target from "Open Signal".
+3. Run from the same external IP as the browser session when possible.
+4. Run registration again immediately (captcha tokens expire quickly):
+
+```bash
+signal-cli -a + register --captcha ''
+signal-cli -a + verify
+```
+
+4. Configure OpenClaw, restart gateway, verify channel:
+
+```bash
+# If you run the gateway as a user systemd service:
+systemctl --user restart openclaw-gateway
+
+# Then verify:
+openclaw doctor
+openclaw channels status --probe
+```
+
+5. Pair your DM sender:
+ - Send any message to the bot number.
+ - Approve code on the server: `openclaw pairing approve signal `.
+ - Save the bot number as a contact on your phone to avoid "Unknown contact".
+
+Important: registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup.
+
+Upstream references:
+
+- `signal-cli` README: `https://github.com/AsamK/signal-cli`
+- Captcha flow: `https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha`
+- Linking flow: `https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)`
+
## External daemon mode (httpUrl)
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point OpenClaw at it:
@@ -191,9 +270,26 @@ Common failures:
- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode.
- DMs ignored: sender is pending pairing approval.
- Group messages ignored: group sender/mention gating blocks delivery.
+- Config validation errors after edits: run `openclaw doctor --fix`.
+- Signal missing from diagnostics: confirm `channels.signal.enabled: true`.
+
+Extra checks:
+
+```bash
+openclaw pairing list signal
+pgrep -af signal-cli
+grep -i "signal" "/tmp/openclaw/openclaw-$(date +%Y-%m-%d).log" | tail -20
+```
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
+## Security notes
+
+- `signal-cli` stores account keys locally (typically `~/.local/share/signal-cli/data/`).
+- Back up Signal account state before server migration or rebuild.
+- Keep `channels.signal.dmPolicy: "pairing"` unless you explicitly want broader DM access.
+- SMS verification is only needed for registration or recovery flows, but losing control of the number/account can complicate re-registration.
+
## Configuration reference (Signal)
Full configuration: [Configuration](/gateway/configuration)
diff --git a/docs/channels/slack.md b/docs/channels/slack.md
index 42844aa6dae..1297fd49457 100644
--- a/docs/channels/slack.md
+++ b/docs/channels/slack.md
@@ -127,6 +127,7 @@ openclaw gateway
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
+- Optional: add `chat:write.customize` if you want outgoing messages to use the active agent identity (custom `username` and icon). `icon_emoji` uses `:emoji_name:` syntax.
For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable.
@@ -136,17 +137,18 @@ For actions/directory reads, user token can be preferred when configured. For wr
- `channels.slack.dm.policy` controls DM access:
+ `channels.slack.dmPolicy` controls DM access (legacy: `channels.slack.dm.policy`):
- `pairing` (default)
- `allowlist`
- - `open` (requires `dm.allowFrom` to include `"*"`)
+ - `open` (requires `channels.slack.allowFrom` to include `"*"`; legacy: `channels.slack.dm.allowFrom`)
- `disabled`
DM flags:
- `dm.enabled` (default true)
- - `dm.allowFrom`
+ - `channels.slack.allowFrom` (preferred)
+ - `dm.allowFrom` (legacy)
- `dm.groupEnabled` (group DMs default false)
- `dm.groupChannels` (optional MPIM allowlist)
@@ -199,6 +201,12 @@ For actions/directory reads, user token can be preferred when configured. For wr
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
- When native commands are enabled, register matching slash commands in Slack (`/` names).
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
+- Native arg menus now adapt their rendering strategy:
+ - up to 5 options: button blocks
+ - 6-100 options: static select menu
+ - more than 100 options: external select with async option filtering when interactivity options handlers are available
+ - if encoded option values exceed Slack limits, the flow falls back to buttons
+- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
Default slash command settings:
@@ -233,6 +241,8 @@ Manual reply tags are supported:
- `[[reply_to_current]]`
- `[[reply_to:]]`
+Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
+
## Media, chunking, and delivery
@@ -282,6 +292,25 @@ Available action groups in current Slack tooling:
- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events.
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
+- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
+ - block actions: selected values, labels, picker values, and `workflow_*` metadata
+ - modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
+
+## Ack reactions
+
+`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+Resolution order:
+
+- `channels.slack.accounts..ackReaction`
+- `channels.slack.ackReaction`
+- `messages.ackReaction`
+- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+Notes:
+
+- Slack expects shortcodes (for example `"eyes"`).
+- Use `""` to disable the reaction for a channel or account.
## Manifest and scope checklist
@@ -396,7 +425,7 @@ openclaw doctor
Check:
- `channels.slack.dm.enabled`
- - `channels.slack.dm.policy`
+ - `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`)
- pairing approvals / allowlist entries
```bash
@@ -436,14 +465,13 @@ Primary reference:
- [Configuration reference - Slack](/gateway/configuration-reference#slack)
-High-signal Slack fields:
-
-- mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*`
-- DM access: `dm.enabled`, `dm.policy`, `dm.allowFrom`, `dm.groupEnabled`, `dm.groupChannels`
-- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
-- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
-- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`
-- ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly`
+ 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`
+ - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
+ - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
+ - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`
+ - ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly`
## Related
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 7a2b57102cf..28a9c227f9d 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -112,7 +112,9 @@ Token resolution order is account-aware. In practice, config values win over env
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
- `channels.telegram.allowFrom` accepts numeric IDs and usernames. `telegram:` / `tg:` prefixes are accepted and normalized.
+ `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
+ The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
+ If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
### Finding your Telegram user ID
@@ -145,6 +147,7 @@ curl "https://api.telegram.org/bot/getUpdates"
- `disabled`
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
+ `groupAllowFrom` entries must be numeric Telegram user IDs.
Example: allow any member in one specific group:
@@ -218,23 +221,20 @@ curl "https://api.telegram.org/bot/getUpdates"
## Feature reference
-
- OpenClaw can stream partial replies with Telegram draft bubbles (`sendMessageDraft`).
+
+ OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
- Requirements:
+ Requirement:
- `channels.telegram.streamMode` is not `"off"` (default: `"partial"`)
- - private chat
- - inbound update includes `message_thread_id`
- - bot topics are enabled (`getMe().has_topics_enabled`)
Modes:
- - `off`: no draft streaming
- - `partial`: frequent draft updates from partial text
- - `block`: chunked draft updates using `channels.telegram.draftChunk`
+ - `off`: no live preview
+ - `partial`: frequent preview updates from partial text
+ - `block`: chunked preview updates using `channels.telegram.draftChunk`
- `draftChunk` defaults for block mode:
+ `draftChunk` defaults for `streamMode: "block"`:
- `minChars: 200`
- `maxChars: 800`
@@ -242,13 +242,17 @@ curl "https://api.telegram.org/bot/getUpdates"
`maxChars` is clamped by `channels.telegram.textChunkLimit`.
- Draft streaming is DM-only; groups/channels do not use draft bubbles.
+ This works in direct chats and groups/topics.
- If you want early real Telegram messages instead of draft updates, use block streaming (`channels.telegram.blockStreaming: true`).
+ For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
+
+ For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
+
+ `streamMode` is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
Telegram-only reasoning stream:
- - `/reasoning stream` sends reasoning to the draft bubble while generating
+ - `/reasoning stream` sends reasoning to the live preview while generating
- final answer is sent without reasoning text
@@ -412,9 +416,11 @@ curl "https://api.telegram.org/bot/getUpdates"
`channels.telegram.replyToMode` controls handling:
- - `first` (default)
+ - `off` (default)
+ - `first`
- `all`
- - `off`
+
+ Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
@@ -565,6 +571,23 @@ curl "https://api.telegram.org/bot/getUpdates"
+
+ `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+ Resolution order:
+
+ - `channels.telegram.accounts..ackReaction`
+ - `channels.telegram.ackReaction`
+ - `messages.ackReaction`
+ - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+ Notes:
+
+ - Telegram expects unicode emoji (for example "👀").
+ - Use `""` to disable the reaction for a channel or account.
+
+
+
Channel config writes are enabled by default (`configWrites !== false`).
@@ -649,7 +672,7 @@ openclaw message send --channel telegram --target @name --message "hi"
- - authorize your sender identity (pairing and/or `allowFrom`)
+ - authorize your sender identity (pairing and/or numeric `allowFrom`)
- command authorization still applies even when group policy is `open`
- `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
@@ -679,9 +702,9 @@ Primary reference:
- `channels.telegram.botToken`: bot token (BotFather).
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
-- `channels.telegram.allowFrom`: DM allowlist (ids/usernames). `open` requires `"*"`.
+- `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 (ids/usernames).
+- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
- `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.
@@ -694,11 +717,11 @@ Primary reference:
- `channels.telegram.groups..topics..requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts..capabilities.inlineButtons`: per-account override.
-- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
+- `channels.telegram.replyToMode`: `off | first | all` (default: `off`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
-- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
+- `channels.telegram.streamMode`: `off | partial | block` (live stream preview).
- `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.
@@ -722,7 +745,7 @@ Telegram-specific high-signal fields:
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
- command/menu: `commands.native`, `customCommands`
- threading/replies: `replyToMode`
-- streaming: `streamMode`, `draftChunk`, `blockStreaming`
+- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md
index b55d996da4e..dbd2015c4ef 100644
--- a/docs/channels/tlon.md
+++ b/docs/channels/tlon.md
@@ -55,6 +55,22 @@ Minimal config (single account):
}
```
+Private/LAN ship URLs (advanced):
+
+By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening).
+If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`),
+you must explicitly opt in:
+
+```json5
+{
+ channels: {
+ tlon: {
+ allowPrivateNetwork: true,
+ },
+ },
+}
+```
+
## Group channels
Auto-discovery is enabled by default. You can also pin channels manually:
diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md
index 0ba3728f5f4..2848947c479 100644
--- a/docs/channels/troubleshooting.md
+++ b/docs/channels/troubleshooting.md
@@ -44,11 +44,12 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats
### Telegram failure signatures
-| Symptom | Fastest check | Fix |
-| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
-| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
-| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
-| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
+| Symptom | Fastest check | Fix |
+| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
+| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
+| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
+| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
+| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md
index 23bbb38f747..d14e38eb5d9 100644
--- a/docs/channels/whatsapp.md
+++ b/docs/channels/whatsapp.md
@@ -144,6 +144,8 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
`allowFrom` accepts E.164-style numbers (normalized internally).
+ Multi-account override: `channels.whatsapp.accounts..dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account.
+
Runtime behavior details:
- pairings are persisted in channel allow-store and merged with configured `allowFrom`
diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md
index 6b4f42143e9..a676a709acb 100644
--- a/docs/cli/hooks.md
+++ b/docs/cli/hooks.md
@@ -32,10 +32,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
-Hooks (3/3 ready)
+Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
+ 📎 bootstrap-extra-files ✓ - Inject extra workspace bootstrap files during agent bootstrap
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
```
@@ -89,7 +90,7 @@ Details:
Source: openclaw-bundled
Path: /path/to/openclaw/hooks/bundled/session-memory/HOOK.md
Handler: /path/to/openclaw/hooks/bundled/session-memory/handler.ts
- Homepage: https://docs.openclaw.ai/hooks#session-memory
+ Homepage: https://docs.openclaw.ai/automation/hooks#session-memory
Events: command:new
Requirements:
@@ -191,6 +192,9 @@ openclaw hooks install
Install a hook pack from a local folder/archive or npm.
+Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
+specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
+
**What it does:**
- Copies the hook pack into `~/.openclaw/hooks/`
@@ -249,6 +253,18 @@ openclaw hooks enable session-memory
**See:** [session-memory documentation](/automation/hooks#session-memory)
+### bootstrap-extra-files
+
+Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
+
+**Enable:**
+
+```bash
+openclaw hooks enable bootstrap-extra-files
+```
+
+**See:** [bootstrap-extra-files documentation](/automation/hooks#bootstrap-extra-files)
+
### command-logger
Logs all command events to a centralized audit file.
diff --git a/docs/cli/message.md b/docs/cli/message.md
index 5e5779dd641..a9ac8c7948b 100644
--- a/docs/cli/message.md
+++ b/docs/cli/message.md
@@ -64,10 +64,11 @@ Name lookup:
- WhatsApp only: `--gif-playback`
- `poll`
- - Channels: WhatsApp/Discord/MS Teams
+ - Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi`
- - Discord only: `--poll-duration-hours`, `--message`
+ - Discord only: `--poll-duration-hours`, `--silent`, `--message`
+ - Telegram only: `--poll-duration-seconds` (5-600), `--silent`, `--poll-anonymous` / `--poll-public`, `--thread-id`
- `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
@@ -200,6 +201,16 @@ openclaw message poll --channel discord \
--poll-multi --poll-duration-hours 48
```
+Create a Telegram poll (auto-close in 2 minutes):
+
+```
+openclaw message poll --channel telegram \
+ --target @mychat \
+ --poll-question "Lunch?" \
+ --poll-option Pizza --poll-option Sushi \
+ --poll-duration-seconds 120 --silent
+```
+
Send a Teams proactive message:
```
diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md
index 60e6fb9888c..59c8a342d35 100644
--- a/docs/cli/nodes.md
+++ b/docs/cli/nodes.md
@@ -64,7 +64,7 @@ Invoke flags:
Flags:
- `--cwd `: working directory.
-- `--env `: env override (repeatable).
+- `--env `: env override (repeatable). Note: node hosts ignore `PATH` overrides (and `tools.exec.pathPrepend` is not applied to node hosts).
- `--command-timeout `: command timeout.
- `--invoke-timeout `: node invoke timeout (default `30000`).
- `--needs-screen-recording`: require screen recording permission.
diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md
index 0dc21fc7af3..cc7eeb18f97 100644
--- a/docs/cli/plugins.md
+++ b/docs/cli/plugins.md
@@ -44,6 +44,9 @@ openclaw plugins install
Security note: treat plugin installs like running code. Prefer pinned versions.
+Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
+specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
+
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md
index b0d99ca907e..8699535aa6b 100644
--- a/docs/concepts/agent-loop.md
+++ b/docs/concepts/agent-loop.md
@@ -81,7 +81,9 @@ See [Hooks](/automation/hooks) for setup and examples.
These run inside the agent loop or gateway pipeline:
-- **`before_agent_start`**: inject context or override system prompt before the run starts.
+- **`before_model_resolve`**: runs pre-session (no `messages`) to deterministically override provider/model before model resolution.
+- **`before_prompt_build`**: runs after session load (with `messages`) to inject `prependContext`/`systemPrompt` before prompt submission.
+- **`before_agent_start`**: legacy compatibility hook that may run in either phase; prefer the explicit hooks above.
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md
index 79e1647e8f5..20b2fffa319 100644
--- a/docs/concepts/agent-workspace.md
+++ b/docs/concepts/agent-workspace.md
@@ -116,7 +116,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
the session and continues. Large bootstrap files are truncated when injected;
-adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
+adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
+`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
`openclaw setup` can recreate missing defaults without overwriting existing
files.
diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md
index 24e1fb69f70..de9582c7144 100644
--- a/docs/concepts/architecture.md
+++ b/docs/concepts/architecture.md
@@ -19,7 +19,10 @@ Last updated: 2026-01-22
- **Nodes** (macOS/iOS/Android/headless) also connect over **WebSocket**, but
declare `role: node` with explicit caps/commands.
- One Gateway per host; it is the only place that opens a WhatsApp session.
-- A **canvas host** (default `18793`) serves agent‑editable HTML and A2UI.
+- The **canvas host** is served by the Gateway HTTP server under:
+ - `/__openclaw__/canvas/` (agent-editable HTML/CSS/JS)
+ - `/__openclaw__/a2ui/` (A2UI host)
+ It uses the same port as the Gateway (default `18789`).
## Components and flows
diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md
index 54b3d30ecab..cc6effb7e64 100644
--- a/docs/concepts/compaction.md
+++ b/docs/concepts/compaction.md
@@ -21,7 +21,7 @@ Compaction **persists** in the session’s JSONL history.
## Configuration
-See [Compaction config & modes](/concepts/compaction) for the `agents.defaults.compaction` settings.
+Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
## Auto-compaction (default on)
diff --git a/docs/concepts/context.md b/docs/concepts/context.md
index 834cc965246..78d755f8576 100644
--- a/docs/concepts/context.md
+++ b/docs/concepts/context.md
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
-Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
+Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
## Skills: what’s injected vs loaded on-demand
diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md
index 9ad902c6c4e..a6c3ef28401 100644
--- a/docs/concepts/memory.md
+++ b/docs/concepts/memory.md
@@ -139,8 +139,8 @@ out to QMD for retrieval. Key points:
- Boot refresh now runs in the background by default so chat startup is not
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
blocking behavior.
-- Searches run via `memory.qmd.searchMode` (default `qmd query --json`; also
- supports `search` and `vsearch`). If the selected mode rejects flags on your
+- Searches run via `memory.qmd.searchMode` (default `qmd search --json`; also
+ supports `vsearch` and `query`). If the selected mode rejects flags on your
QMD build, OpenClaw retries with `qmd query`. If QMD fails or the binary is
missing, OpenClaw automatically falls back to the builtin SQLite manager so
memory tools keep working.
@@ -159,10 +159,6 @@ out to QMD for retrieval. Key points:
```bash
# Pick the same state dir OpenClaw uses
STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
- if [ -d "$HOME/.moltbot" ] && [ ! -d "$HOME/.openclaw" ] \
- && [ -z "${OPENCLAW_STATE_DIR:-}" ]; then
- STATE_DIR="$HOME/.moltbot"
- fi
export XDG_CONFIG_HOME="$STATE_DIR/agents/main/qmd/xdg-config"
export XDG_CACHE_HOME="$STATE_DIR/agents/main/qmd/xdg-cache"
@@ -178,8 +174,8 @@ out to QMD for retrieval. Key points:
**Config surface (`memory.qmd.*`)**
- `command` (default `qmd`): override the executable path.
-- `searchMode` (default `query`): pick which QMD command backs
- `memory_search` (`query`, `search`, `vsearch`).
+- `searchMode` (default `search`): pick which QMD command backs
+ `memory_search` (`search`, `vsearch`, `query`).
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
stable `name`).
@@ -193,6 +189,12 @@ out to QMD for retrieval. Key points:
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
hits in groups/channels.
+ - `match.keyPrefix` matches the **normalized** session key (lowercased, with any
+ leading `agent::` stripped). Example: `discord:channel:`.
+ - `match.rawKeyPrefix` matches the **raw** session key (lowercased), including
+ `agent::`. Example: `agent:main:discord:`.
+ - Legacy: `match.keyPrefix: "agent:..."` is still treated as a raw-key prefix,
+ but prefer `rawKeyPrefix` for clarity.
- When `scope` denies a search, OpenClaw logs a warning with the derived
`channel`/`chatType` so empty results are easier to debug.
- Snippets sourced outside the workspace show up as
@@ -220,7 +222,13 @@ memory: {
limits: { maxResults: 6, timeoutMs: 4000 },
scope: {
default: "deny",
- rules: [{ action: "allow", match: { chatType: "direct" } }]
+ rules: [
+ { action: "allow", match: { chatType: "direct" } },
+ // Normalized session-key prefix (strips `agent::`).
+ { action: "deny", match: { keyPrefix: "discord:channel:" } },
+ // Raw session-key prefix (includes `agent::`).
+ { action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
+ ]
},
paths: [
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
@@ -388,11 +396,11 @@ But it can be weak at exact, high-signal tokens:
- IDs (`a828e60`, `b3b9895a…`)
- code symbols (`memorySearch.query.hybrid`)
-- error strings (“sqlite-vec unavailable”)
+- error strings ("sqlite-vec unavailable")
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
-good results for both “natural language” queries and “needle in a haystack” queries.
+good results for both "natural language" queries and "needle in a haystack" queries.
#### How we merge results (the current design)
@@ -415,13 +423,142 @@ Notes:
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
-- If FTS5 can’t be created, we keep vector-only search (no hard failure).
+- If FTS5 can't be created, we keep vector-only search (no hard failure).
-This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
+This isn't "IR-theory perfect", but it's simple, fast, and tends to improve recall/precision on real notes.
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
(min/max or z-score) before mixing.
-Config:
+#### Post-processing pipeline
+
+After merging vector and keyword scores, two optional post-processing stages
+refine the result list before it reaches the agent:
+
+```
+Vector + Keyword → Weighted Merge → Temporal Decay → Sort → MMR → Top-K Results
+```
+
+Both stages are **off by default** and can be enabled independently.
+
+#### MMR re-ranking (diversity)
+
+When hybrid search returns results, multiple chunks may contain similar or overlapping content.
+For example, searching for "home network setup" might return five nearly identical snippets
+from different daily notes that all mention the same router configuration.
+
+**MMR (Maximal Marginal Relevance)** re-ranks the results to balance relevance with diversity,
+ensuring the top results cover different aspects of the query instead of repeating the same information.
+
+How it works:
+
+1. Results are scored by their original relevance (vector + BM25 weighted score).
+2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × max_similarity_to_selected`.
+3. Similarity between results is measured using Jaccard text similarity on tokenized content.
+
+The `lambda` parameter controls the trade-off:
+
+- `lambda = 1.0` → pure relevance (no diversity penalty)
+- `lambda = 0.0` → maximum diversity (ignores relevance)
+- Default: `0.7` (balanced, slight relevance bias)
+
+**Example — query: "home network setup"**
+
+Given these memory files:
+
+```
+memory/2026-02-10.md → "Configured Omada router, set VLAN 10 for IoT devices"
+memory/2026-02-08.md → "Configured Omada router, moved IoT to VLAN 10"
+memory/2026-02-05.md → "Set up AdGuard DNS on 192.168.10.2"
+memory/network.md → "Router: Omada ER605, AdGuard: 192.168.10.2, VLAN 10: IoT"
+```
+
+Without MMR — top 3 results:
+
+```
+1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
+2. memory/2026-02-08.md (score: 0.89) ← router + VLAN (near-duplicate!)
+3. memory/network.md (score: 0.85) ← reference doc
+```
+
+With MMR (λ=0.7) — top 3 results:
+
+```
+1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
+2. memory/network.md (score: 0.85) ← reference doc (diverse!)
+3. memory/2026-02-05.md (score: 0.78) ← AdGuard DNS (diverse!)
+```
+
+The near-duplicate from Feb 8 drops out, and the agent gets three distinct pieces of information.
+
+**When to enable:** If you notice `memory_search` returning redundant or near-duplicate snippets,
+especially with daily notes that often repeat similar information across days.
+
+#### Temporal decay (recency boost)
+
+Agents with daily notes accumulate hundreds of dated files over time. Without decay,
+a well-worded note from six months ago can outrank yesterday's update on the same topic.
+
+**Temporal decay** applies an exponential multiplier to scores based on the age of each result,
+so recent memories naturally rank higher while old ones fade:
+
+```
+decayedScore = score × e^(-λ × ageInDays)
+```
+
+where `λ = ln(2) / halfLifeDays`.
+
+With the default half-life of 30 days:
+
+- Today's notes: **100%** of original score
+- 7 days ago: **~84%**
+- 30 days ago: **50%**
+- 90 days ago: **12.5%**
+- 180 days ago: **~1.6%**
+
+**Evergreen files are never decayed:**
+
+- `MEMORY.md` (root memory file)
+- Non-dated files in `memory/` (e.g., `memory/projects.md`, `memory/network.md`)
+- These contain durable reference information that should always rank normally.
+
+**Dated daily files** (`memory/YYYY-MM-DD.md`) use the date extracted from the filename.
+Other sources (e.g., session transcripts) fall back to file modification time (`mtime`).
+
+**Example — query: "what's Rod's work schedule?"**
+
+Given these memory files (today is Feb 10):
+
+```
+memory/2025-09-15.md → "Rod works Mon-Fri, standup at 10am, pairing at 2pm" (148 days old)
+memory/2026-02-10.md → "Rod has standup at 14:15, 1:1 with Zeb at 14:45" (today)
+memory/2026-02-03.md → "Rod started new team, standup moved to 14:15" (7 days old)
+```
+
+Without decay:
+
+```
+1. memory/2025-09-15.md (score: 0.91) ← best semantic match, but stale!
+2. memory/2026-02-10.md (score: 0.82)
+3. memory/2026-02-03.md (score: 0.80)
+```
+
+With decay (halfLife=30):
+
+```
+1. memory/2026-02-10.md (score: 0.82 × 1.00 = 0.82) ← today, no decay
+2. memory/2026-02-03.md (score: 0.80 × 0.85 = 0.68) ← 7 days, mild decay
+3. memory/2025-09-15.md (score: 0.91 × 0.03 = 0.03) ← 148 days, nearly gone
+```
+
+The stale September note drops to the bottom despite having the best raw semantic match.
+
+**When to enable:** If your agent has months of daily notes and you find that old,
+stale information outranks recent context. A half-life of 30 days works well for
+daily-note-heavy workflows; increase it (e.g., 90 days) if you reference older notes frequently.
+
+#### Configuration
+
+Both features are configured under `memorySearch.query.hybrid`:
```json5
agents: {
@@ -432,7 +569,17 @@ agents: {
enabled: true,
vectorWeight: 0.7,
textWeight: 0.3,
- candidateMultiplier: 4
+ candidateMultiplier: 4,
+ // Diversity: reduce redundant results
+ mmr: {
+ enabled: true, // default: false
+ lambda: 0.7 // 0 = max diversity, 1 = max relevance
+ },
+ // Recency: boost newer memories
+ temporalDecay: {
+ enabled: true, // default: false
+ halfLifeDays: 30 // score halves every 30 days
+ }
}
}
}
@@ -440,6 +587,12 @@ agents: {
}
```
+You can enable either feature independently:
+
+- **MMR only** — useful when you have many similar notes but age doesn't matter.
+- **Temporal decay only** — useful when recency matters but your results are already diverse.
+- **Both** — recommended for agents with large, long-running daily note histories.
+
### Embedding cache
OpenClaw can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
@@ -535,7 +688,7 @@ Notes:
### Local embedding auto-download
-- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
+- Default local embedding model: `hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB).
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md
index 027654a9006..8f4c05a7cc8 100644
--- a/docs/concepts/multi-agent.md
+++ b/docs/concepts/multi-agent.md
@@ -125,11 +125,15 @@ Notes:
Bindings are **deterministic** and **most-specific wins**:
1. `peer` match (exact DM/group/channel id)
-2. `guildId` (Discord)
-3. `teamId` (Slack)
-4. `accountId` match for a channel
-5. channel-level match (`accountId: "*"`)
-6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
+2. `parentPeer` match (thread inheritance)
+3. `guildId + roles` (Discord role routing)
+4. `guildId` (Discord)
+5. `teamId` (Slack)
+6. `accountId` match for a channel
+7. channel-level match (`accountId: "*"`)
+8. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
+
+If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
## Multiple accounts / phone numbers
diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md
index 945f3883f66..1dc5fb8cca5 100644
--- a/docs/concepts/session-tool.md
+++ b/docs/concepts/session-tool.md
@@ -176,12 +176,24 @@ Behavior:
## Sandbox Session Visibility
-Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
+Session tools can be scoped to reduce cross-session access.
+
+Default behavior:
+
+- `tools.sessions.visibility` defaults to `tree` (current session + spawned subagent sessions).
+- For sandboxed sessions, `agents.defaults.sandbox.sessionToolsVisibility` can hard-clamp visibility.
Config:
```json5
{
+ tools: {
+ sessions: {
+ // "self" | "tree" | "agent" | "all"
+ // default: "tree"
+ visibility: "tree",
+ },
+ },
agents: {
defaults: {
sandbox: {
@@ -192,3 +204,11 @@ Config:
},
}
```
+
+Notes:
+
+- `self`: only the current session key.
+- `tree`: current session + sessions spawned by the current session.
+- `agent`: any session belonging to the current agent id.
+- `all`: any session (cross-agent access still requires `tools.agentToAgent`).
+- When a session is sandboxed and `sessionToolsVisibility="spawned"`, OpenClaw clamps visibility to `tree` even if you set `tools.sessions.visibility="all"`.
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 54dfb21327f..edd6f415d28 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -123,6 +123,8 @@ Block delivery for specific session types without listing individual ids.
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } },
{ action: "deny", match: { keyPrefix: "cron:" } },
+ // Match the raw session key (including the `agent::` prefix).
+ { action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
],
default: "allow",
},
diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md
index b9ea09fd36c..b81f87606d7 100644
--- a/docs/concepts/streaming.md
+++ b/docs/concepts/streaming.md
@@ -1,9 +1,9 @@
---
-summary: "Streaming + chunking behavior (block replies, draft streaming, limits)"
+summary: "Streaming + chunking behavior (block replies, Telegram preview streaming, limits)"
read_when:
- Explaining how streaming or chunking works on channels
- Changing block streaming or channel chunking behavior
- - Debugging duplicate/early block replies or draft streaming
+ - Debugging duplicate/early block replies or Telegram preview streaming
title: "Streaming and Chunking"
---
@@ -12,9 +12,9 @@ title: "Streaming and Chunking"
OpenClaw has two separate “streaming” layers:
- **Block streaming (channels):** emit completed **blocks** as the assistant writes. These are normal channel messages (not token deltas).
-- **Token-ish streaming (Telegram only):** update a **draft bubble** with partial text while generating; final message is sent at the end.
+- **Token-ish streaming (Telegram only):** update a temporary **preview message** with partial text while generating.
-There is **no real token streaming** to external channel messages today. Telegram draft streaming is the only partial-stream surface.
+There is **no true token-delta streaming** to channel messages today. Telegram preview streaming is the only partial-stream surface.
## Block streaming (channel messages)
@@ -99,37 +99,38 @@ This maps to:
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
**Channel note:** For non-Telegram channels, block streaming is **off unless**
-`*.blockStreaming` is explicitly set to `true`. Telegram can stream drafts
+`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
(`channels.telegram.streamMode`) without block replies.
Config location reminder: the `blockStreaming*` defaults live under
`agents.defaults`, not the root config.
-## Telegram draft streaming (token-ish)
+## Telegram preview streaming (token-ish)
-Telegram is the only channel with draft streaming:
+Telegram is the only channel with live preview streaming:
-- Uses Bot API `sendMessageDraft` in **private chats with topics**.
+- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
- `channels.telegram.streamMode: "partial" | "block" | "off"`.
- - `partial`: draft updates with the latest stream text.
- - `block`: draft updates in chunked blocks (same chunker rules).
- - `off`: no draft streaming.
-- Draft chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
-- Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram channels.
-- Final reply is still a normal message.
-- `/reasoning stream` writes reasoning into the draft bubble (Telegram only).
-
-When draft streaming is active, OpenClaw disables block streaming for that reply to avoid double-streaming.
+ - `partial`: preview updates with latest stream text.
+ - `block`: preview updates in chunked blocks (same chunker rules).
+ - `off`: no preview streaming.
+- Preview chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
+- Preview streaming is separate from block streaming.
+- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
+- Text-only finals are applied by editing the preview message in place.
+- Non-text/complex finals fall back to normal final message delivery.
+- `/reasoning stream` writes reasoning into the live preview (Telegram only).
```
-Telegram (private + topics)
- └─ sendMessageDraft (draft bubble)
- ├─ streamMode=partial → update latest text
- └─ streamMode=block → chunker updates draft
- └─ final reply → normal message
+Telegram
+ └─ sendMessage (temporary preview message)
+ ├─ streamMode=partial → edit latest text
+ └─ streamMode=block → chunker + edit updates
+ └─ final text-only reply → final edit on same message
+ └─ fallback: cleanup preview + normal final delivery (media/complex)
```
Legend:
-- `sendMessageDraft`: Telegram draft bubble (not a real message).
-- `final reply`: normal Telegram message send.
+- `preview message`: temporary Telegram message updated during generation.
+- `final edit`: in-place edit on the same preview message (text-only).
diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md
index 21edbff830d..b7ed42534b3 100644
--- a/docs/concepts/system-prompt.md
+++ b/docs/concepts/system-prompt.md
@@ -8,7 +8,7 @@ title: "System Prompt"
# System Prompt
-OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use the p-coding-agent default prompt.
+OpenClaw builds a custom system prompt for every agent run. The prompt is **OpenClaw-owned** and does not use the pi-coding-agent default prompt.
The prompt is assembled by OpenClaw and injected into each agent run.
@@ -71,8 +71,9 @@ compaction.
> do not count against the context window unless the model explicitly reads them.
Large files are truncated with a marker. The max per-file size is controlled by
-`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
-short missing-file marker.
+`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
+content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
+(default: 150000). Missing files inject a short missing-file marker.
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
are filtered out to keep the sub-agent context small).
diff --git a/docs/docs.json b/docs/docs.json
index af750f0bc8e..0952953b0a5 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -319,6 +319,10 @@
"source": "/docker",
"destination": "/install/docker"
},
+ {
+ "source": "/podman",
+ "destination": "/install/podman"
+ },
{
"source": "/doctor",
"destination": "/gateway/doctor"
@@ -786,6 +790,10 @@
{
"source": "/platforms/northflank",
"destination": "/install/northflank"
+ },
+ {
+ "source": "/gateway/trusted-proxy",
+ "destination": "/gateway/trusted-proxy-auth"
}
],
"navigation": {
@@ -832,7 +840,13 @@
},
{
"group": "Other install methods",
- "pages": ["install/docker", "install/nix", "install/ansible", "install/bun"]
+ "pages": [
+ "install/docker",
+ "install/podman",
+ "install/nix",
+ "install/ansible",
+ "install/bun"
+ ]
},
{
"group": "Maintenance",
@@ -1106,6 +1120,7 @@
"gateway/configuration-reference",
"gateway/configuration-examples",
"gateway/authentication",
+ "gateway/trusted-proxy-auth",
"gateway/health",
"gateway/heartbeat",
"gateway/doctor",
@@ -1285,7 +1300,7 @@
},
{
"group": "Contributing",
- "pages": ["help/submitting-a-pr", "help/submitting-an-issue", "ci"]
+ "pages": ["ci"]
},
{
"group": "Docs meta",
@@ -1812,10 +1827,6 @@
"group": "开发者设置",
"pages": ["zh-CN/start/setup"]
},
- {
- "group": "贡献",
- "pages": ["zh-CN/help/submitting-a-pr", "zh-CN/help/submitting-an-issue"]
- },
{
"group": "文档元信息",
"pages": ["zh-CN/start/hubs", "zh-CN/start/docs-directory"]
diff --git a/docs/experiments/.DS_Store b/docs/experiments/.DS_Store
new file mode 100644
index 00000000000..b13221a744b
Binary files /dev/null and b/docs/experiments/.DS_Store differ
diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md
new file mode 100644
index 00000000000..352850c82f6
--- /dev/null
+++ b/docs/experiments/plans/pty-process-supervision.md
@@ -0,0 +1,192 @@
+---
+summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup"
+owner: "openclaw"
+status: "in-progress"
+last_updated: "2026-02-15"
+title: "PTY and Process Supervision Plan"
+---
+
+# PTY and Process Supervision Plan
+
+## 1. Problem and goal
+
+We need one reliable lifecycle for long-running command execution across:
+
+- `exec` foreground runs
+- `exec` background runs
+- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`)
+- CLI agent runner subprocesses
+
+The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics.
+
+## 2. Scope and boundaries
+
+- Keep implementation internal in `src/process/supervisor`.
+- Do not create a new package for this.
+- Keep current behavior compatibility where practical.
+- Do not broaden scope to terminal replay or tmux style session persistence.
+
+## 3. Implemented in this branch
+
+### Supervisor baseline already present
+
+- Supervisor module is in place under `src/process/supervisor/*`.
+- Exec runtime and CLI runner are already routed through supervisor spawn and wait.
+- Registry finalization is idempotent.
+
+### This pass completed
+
+1. Explicit PTY command contract
+
+- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`.
+- PTY runs require `ptyCommand` instead of reusing generic `argv`.
+- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`.
+- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`.
+
+2. Process layer type decoupling
+
+- Supervisor types no longer import `SessionStdin` from agents.
+- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`).
+- Adapters now depend only on process level types:
+ - `src/process/supervisor/adapters/child.ts`
+ - `src/process/supervisor/adapters/pty.ts`
+
+3. Process tool lifecycle ownership improvement
+
+- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first.
+- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses.
+- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested.
+
+4. Single source watchdog defaults
+
+- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`.
+- `src/agents/cli-backends.ts` consumes the shared defaults.
+- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults.
+
+5. Dead helper cleanup
+
+- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`.
+
+6. Direct supervisor path tests added
+
+- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation.
+
+7. Reliability gap fixes completed
+
+- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses.
+- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths.
+- Added shared process-tree utility in `src/process/kill-tree.ts`.
+
+8. PTY contract edge-case coverage added
+
+- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection.
+- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation.
+
+## 4. Remaining gaps and decisions
+
+### Reliability status
+
+The two required reliability gaps for this pass are now closed:
+
+- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses.
+- child cancel/timeout now uses process-tree kill semantics for default kill path.
+- Regression tests were added for both behaviors.
+
+### Durability and startup reconciliation
+
+Restart behavior is now explicitly defined as in-memory lifecycle only.
+
+- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design.
+- Active runs are not recovered after process restart.
+- This boundary is intentional for this implementation pass to avoid partial persistence risks.
+
+### Maintainability follow-ups
+
+1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up.
+
+## 5. Implementation plan
+
+The implementation pass for required reliability and contract items is complete.
+
+Completed:
+
+- `process kill/remove` fallback real termination
+- process-tree cancellation for child adapter default kill path
+- regression tests for fallback kill and child adapter kill path
+- PTY command edge-case tests under explicit `ptyCommand`
+- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design
+
+Optional follow-up:
+
+- split `runExecProcess` into focused helpers with no behavior drift
+
+## 6. File map
+
+### Process supervisor
+
+- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract.
+- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`.
+- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types.
+- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained.
+
+### Exec and process integration
+
+- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path.
+- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination.
+- `src/agents/bash-tools.shared.ts` removed direct kill helper path.
+
+### CLI reliability
+
+- `src/agents/cli-watchdog-defaults.ts` added as shared baseline.
+- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults.
+
+## 7. Validation run in this pass
+
+Unit tests:
+
+- `pnpm vitest src/process/supervisor/registry.test.ts`
+- `pnpm vitest src/process/supervisor/supervisor.test.ts`
+- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts`
+- `pnpm vitest src/process/supervisor/adapters/child.test.ts`
+- `pnpm vitest src/agents/cli-backends.test.ts`
+- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts`
+- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts`
+- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts`
+- `pnpm vitest src/process/exec.test.ts`
+
+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`
+
+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.
+
+## 8. Operational guarantees preserved
+
+- Exec env hardening behavior is unchanged.
+- Approval and allowlist flow is unchanged.
+- Output sanitization and output caps are unchanged.
+- PTY adapter still guarantees wait settlement on forced kill and listener disposal.
+
+## 9. Definition of done
+
+1. Supervisor is lifecycle owner for managed runs.
+2. PTY spawn uses explicit command contract with no argv reconstruction.
+3. Process layer has no type dependency on agent layer for supervisor stdin contracts.
+4. Watchdog defaults are single source.
+5. Targeted unit and e2e tests remain green.
+6. Restart durability boundary is explicitly documented or fully implemented.
+
+## 10. Summary
+
+The branch now has a coherent and safer supervision shape:
+
+- explicit PTY contract
+- cleaner process layering
+- supervisor driven cancellation path for process operations
+- real fallback termination when supervisor lookup misses
+- process-tree cancellation for child-run default kill paths
+- unified watchdog defaults
+- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass)
diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md
index 30f50852df1..9d745a9e884 100644
--- a/docs/gateway/background-process.md
+++ b/docs/gateway/background-process.md
@@ -46,6 +46,7 @@ Config (preferred):
- `tools.exec.timeoutSec` (default 1800)
- `tools.exec.cleanupMs` (default 1800000)
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
+- `tools.exec.notifyOnExitEmptySuccess` (default false): when true, also enqueue completion events for successful backgrounded runs that produced no output.
## process tool
@@ -66,7 +67,9 @@ Notes:
- Session logs are only saved to chat history if you run `process poll/log` and the tool result is recorded.
- `process` is scoped per agent; it only sees sessions started by that agent.
- `process list` includes a derived `name` (command verb + target) for quick scans.
-- `process log` uses line-based `offset`/`limit` (omit `offset` to grab the last N lines).
+- `process log` uses line-based `offset`/`limit`.
+- When both `offset` and `limit` are omitted, it returns the last 200 lines and includes a paging hint.
+- When `offset` is provided and `limit` is omitted, it returns from `offset` to the end (not capped to 200).
## Examples
diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md
index 9e2ad8753ae..03643717d55 100644
--- a/docs/gateway/bonjour.md
+++ b/docs/gateway/bonjour.md
@@ -94,12 +94,19 @@ The Gateway advertises small non‑secret hints to make UI flows convenient:
- `gatewayPort=` (Gateway WS + HTTP)
- `gatewayTls=1` (only when TLS is enabled)
- `gatewayTlsSha256=` (only when TLS is enabled and fingerprint is available)
-- `canvasPort=` (only when the canvas host is enabled; default `18793`)
+- `canvasPort=` (only when the canvas host is enabled; currently the same as `gatewayPort`)
- `sshPort=` (defaults to 22 when not overridden)
- `transport=gateway`
- `cliPath=` (optional; absolute path to a runnable `openclaw` entrypoint)
- `tailnetDns=` (optional hint when Tailnet is available)
+Security notes:
+
+- Bonjour/mDNS TXT records are **unauthenticated**. Clients must not treat TXT as authoritative routing.
+- Clients should route using the resolved service endpoint (SRV + A/AAAA). Treat `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256` as hints only.
+- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
+- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require explicit user confirmation before trusting a first-time fingerprint.
+
## Debugging on macOS
Useful built‑in tools:
diff --git a/docs/gateway/bridge-protocol.md b/docs/gateway/bridge-protocol.md
index 1c23e38186b..850de1c2d51 100644
--- a/docs/gateway/bridge-protocol.md
+++ b/docs/gateway/bridge-protocol.md
@@ -35,7 +35,9 @@ Legacy `bridge.*` config keys are no longer part of the config schema.
- Legacy default listener port was `18790` (current builds do not start a TCP bridge).
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
-`bridgeTlsSha256` so nodes can pin the certificate.
+`bridgeTlsSha256` as a non-secret hint. Note that Bonjour/mDNS TXT records are
+unauthenticated; clients must not treat the advertised fingerprint as an
+authoritative pin without explicit user intent or other out-of-band verification.
## Handshake + pairing
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index ca77eef132d..960f37c005b 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -363,7 +363,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
path: "/hooks",
token: "shared-secret",
presets: ["gmail"],
- transformsDir: "~/.openclaw/hooks",
+ transformsDir: "~/.openclaw/hooks/transforms",
mappings: [
{
id: "gmail-hook",
@@ -380,7 +380,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
thinking: "low",
timeoutSeconds: 300,
transform: {
- module: "./transforms/gmail.js",
+ module: "gmail.js",
export: "transformGmail",
},
},
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 8c58cd4e94a..92e4f9d436b 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -93,7 +93,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- Legacy single-account Baileys auth dir is migrated by `openclaw doctor` into `whatsapp/default`.
-- Per-account override: `channels.whatsapp.accounts..sendReadReceipts`.
+- Per-account overrides: `channels.whatsapp.accounts..sendReadReceipts`, `channels.whatsapp.accounts..dmPolicy`, `channels.whatsapp.accounts..allowFrom`.
@@ -155,7 +155,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
-- Draft streaming uses Telegram `sendMessageDraft` (requires private chat topics).
+- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
- Retry policy: see [Retry policy](/concepts/retry).
### Discord
@@ -186,13 +186,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
moderation: false,
},
replyToMode: "off", // off | first | all
- dm: {
- enabled: true,
- policy: "pairing",
- allowFrom: ["1234567890", "steipete"],
- groupEnabled: false,
- groupChannels: ["openclaw-dm"],
- },
+ dmPolicy: "pairing",
+ allowFrom: ["1234567890", "steipete"],
+ dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
guilds: {
"123456789012345678": {
slug: "friends-of-openclaw",
@@ -215,6 +211,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
textChunkLimit: 2000,
chunkMode: "length", // length | newline
maxLinesPerMessage: 17,
+ ui: {
+ components: {
+ accentColor: "#5865F2",
+ },
+ },
retry: {
attempts: 3,
minDelayMs: 500,
@@ -231,6 +232,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
+- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages).
@@ -276,13 +278,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
- dm: {
- enabled: true,
- policy: "pairing",
- allowFrom: ["U123", "U456", "*"],
- groupEnabled: false,
- groupChannels: ["G123"],
- },
+ dmPolicy: "pairing",
+ allowFrom: ["U123", "U456", "*"],
+ dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
channels: {
C123: { allow: true, requireMention: true, allowBots: false },
"#general": {
@@ -589,6 +587,16 @@ Max characters per workspace bootstrap file before truncation. Default: `20000`.
}
```
+### `agents.defaults.bootstrapTotalMaxChars`
+
+Max total characters injected across all workspace bootstrap files. Default: `150000`.
+
+```json5
+{
+ agents: { defaults: { bootstrapTotalMaxChars: 150000 } },
+}
+```
+
### `agents.defaults.userTimezone`
Timezone for system prompt context (not message timestamps). Falls back to host timezone.
@@ -710,6 +718,7 @@ Periodic heartbeat runs.
target: "last", // last | whatsapp | telegram | discord | ... | none
prompt: "Read HEARTBEAT.md if it exists...",
ackMaxChars: 300,
+ suppressToolErrorWarnings: false,
},
},
},
@@ -717,6 +726,7 @@ Periodic heartbeat runs.
```
- `every`: duration string (ms/s/m/h). Default: `30m`.
+- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- 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.
@@ -933,6 +943,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config.
- `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
+- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
@@ -1171,7 +1182,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`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`.
- **`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), or `keyPrefix`. First deny wins.
+- **`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.
@@ -1229,6 +1240,8 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
### Ack reaction
- Defaults to active agent's `identity.emoji`, otherwise `"👀"`. Set `""` to disable.
+- Per-channel overrides: `channels..ackReaction`, `channels..accounts..ackReaction`.
+- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
- `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only).
@@ -1394,6 +1407,7 @@ Controls elevated (host) exec access:
timeoutSec: 1800,
cleanupMs: 1800000,
notifyOnExit: true,
+ notifyOnExitEmptySuccess: false,
applyPatch: {
enabled: false,
allowModels: ["gpt-5.2"],
@@ -1403,6 +1417,39 @@ Controls elevated (host) exec access:
}
```
+### `tools.loopDetection`
+
+Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection.
+Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
+
+```json5
+{
+ tools: {
+ loopDetection: {
+ enabled: true,
+ historySize: 30,
+ warningThreshold: 10,
+ criticalThreshold: 20,
+ globalCircuitBreakerThreshold: 30,
+ detectors: {
+ genericRepeat: true,
+ knownPollNoProgress: true,
+ pingPong: true,
+ },
+ },
+ },
+}
+```
+
+- `historySize`: max tool-call history retained for loop analysis.
+- `warningThreshold`: repeating no-progress pattern threshold for warnings.
+- `criticalThreshold`: higher repeating threshold for blocking critical loops.
+- `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
+- `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
+- `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
+- `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
+- If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
+
### `tools.web`
```json5
@@ -1496,6 +1543,31 @@ Provider auth follows standard order: auth profiles → env vars → `models.pro
}
```
+### `tools.sessions`
+
+Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).
+
+Default: `tree` (current session + sessions spawned by it, such as subagents).
+
+```json5
+{
+ tools: {
+ sessions: {
+ // "self" | "tree" | "agent" | "all"
+ visibility: "tree",
+ },
+ },
+}
+```
+
+Notes:
+
+- `self`: only the current session key.
+- `tree`: current session + sessions spawned by the current session (subagents).
+- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
+- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
+- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
+
### `tools.subagents`
```json5
@@ -1889,9 +1961,10 @@ See [Plugins](/tools/plugin).
port: 18789,
bind: "loopback",
auth: {
- mode: "token", // token | password
+ mode: "token", // token | password | trusted-proxy
token: "your-token",
// password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD
+ // trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth
allowTailscale: true,
rateLimit: {
maxAttempts: 10,
@@ -1934,6 +2007,7 @@ See [Plugins](/tools/plugin).
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
+- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). 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).
@@ -1985,7 +2059,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
allowedSessionKeyPrefixes: ["hook:"],
allowedAgentIds: ["hooks", "main"],
presets: ["gmail"],
- transformsDir: "~/.openclaw/hooks",
+ transformsDir: "~/.openclaw/hooks/transforms",
mappings: [
{
match: { path: "gmail" },
@@ -2019,6 +2093,7 @@ Auth: `Authorization: Bearer ` or `x-openclaw-token: `.
- `match.source` matches a payload field for generic paths.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module returning a hook action.
+ - `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
- `agentId` routes to a specific agent; unknown IDs fall back to default.
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
@@ -2063,14 +2138,18 @@ Auth: `Authorization: Bearer ` or `x-openclaw-token: `.
{
canvasHost: {
root: "~/.openclaw/workspace/canvas",
- port: 18793,
liveReload: true,
// enabled: false, // or OPENCLAW_SKIP_CANVAS_HOST=1
},
}
```
-- Serves HTML/CSS/JS over HTTP for iOS/Android nodes.
+- Serves agent-editable HTML/CSS/JS and A2UI over HTTP under the Gateway port:
+ - `http://:/__openclaw__/canvas/`
+ - `http://:/__openclaw__/a2ui/`
+- Local-only: keep `gateway.bind: "loopback"` (default).
+- Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces.
+- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway allows a private-IP fallback so the node can load canvas/A2UI without leaking secrets into URLs.
- Injects live-reload client into served HTML.
- Auto-creates starter `index.html` when empty.
- Also serves A2UI at `/__openclaw__/a2ui/`.
@@ -2276,12 +2355,16 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
cron: {
enabled: true,
maxConcurrentRuns: 2,
+ 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
},
}
```
- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
+- `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`.
See [Cron Jobs](/automation/cron-jobs).
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 09c8f6c2968..46ba7af67b9 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -61,7 +61,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
## Strict validation
-OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**.
+OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**. The only root-level exception is `$schema` (string), so editors can attach JSON Schema metadata.
When validation fails:
diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md
index 644bd7b1966..af1144125d3 100644
--- a/docs/gateway/discovery.md
+++ b/docs/gateway/discovery.md
@@ -64,10 +64,17 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
- `gatewayPort=18789` (Gateway WS + HTTP)
- `gatewayTls=1` (only when TLS is enabled)
- `gatewayTlsSha256=` (only when TLS is enabled and fingerprint is available)
- - `canvasPort=18793` (default canvas host port; serves `/__openclaw__/canvas/`)
+ - `canvasPort=` (canvas host port; currently the same as `gatewayPort` when the canvas host is enabled)
- `cliPath=` (optional; absolute path to a runnable `openclaw` entrypoint or binary)
- `tailnetDns=` (optional hint; auto-detected when Tailscale is available)
+Security notes:
+
+- Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only.
+- Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`.
+- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
+- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification).
+
Disable/override:
- `OPENCLAW_DISABLE_BONJOUR=1` disables advertising.
diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md
index 6c467d2ae10..a450218f2ce 100644
--- a/docs/gateway/heartbeat.md
+++ b/docs/gateway/heartbeat.md
@@ -209,6 +209,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `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).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
+- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
- `"local"`: always uses the host system timezone.
diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md
index 5bc641e1cf2..d6f35e08a46 100644
--- a/docs/gateway/multiple-gateways.md
+++ b/docs/gateway/multiple-gateways.md
@@ -79,7 +79,7 @@ openclaw --profile rescue gateway install
Base port = `gateway.port` (or `OPENCLAW_GATEWAY_PORT` / `--port`).
- browser control service port = base + 2 (loopback only)
-- `canvasHost.port = base + 4`
+- canvas host is served on the Gateway HTTP server (same port as `gateway.port`)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
If you override any of these in config or env, you must keep them unique per instance.
diff --git a/docs/gateway/network-model.md b/docs/gateway/network-model.md
index 1cbd6a99b3f..c7f65aa22dd 100644
--- a/docs/gateway/network-model.md
+++ b/docs/gateway/network-model.md
@@ -13,5 +13,8 @@ process that owns channel connections and the WebSocket control plane.
- One Gateway per host is recommended. It is the only process allowed to own the WhatsApp Web session. For rescue bots or strict isolation, run multiple gateways with isolated profiles and ports. See [Multiple gateways](/gateway/multiple-gateways).
- Loopback first: the Gateway WS defaults to `ws://127.0.0.1:18789`. The wizard generates a gateway token by default, even for loopback. For tailnet access, run `openclaw gateway --bind tailnet --token ...` because tokens are required for non-loopback binds.
- Nodes connect to the Gateway WS over LAN, tailnet, or SSH as needed. The legacy TCP bridge is deprecated.
-- Canvas host is an HTTP file server on `canvasHost.port` (default `18793`) serving `/__openclaw__/canvas/` for node WebViews. See [Gateway configuration](/gateway/configuration) (`canvasHost`).
+- Canvas host is served by the Gateway HTTP server on the **same port** as the Gateway (default `18789`):
+ - `/__openclaw__/canvas/`
+ - `/__openclaw__/a2ui/`
+ When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth (loopback requests are exempt). See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`).
- Remote use is typically SSH tunnel or tailnet VPN. See [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md
index 45062ea9dfb..fe27d2c51ad 100644
--- a/docs/gateway/sandboxing.md
+++ b/docs/gateway/sandboxing.md
@@ -71,7 +71,12 @@ Format: `host:container:mode` (e.g., `"/home/user/source:/source:rw"`).
Global and per-agent binds are **merged** (not replaced). Under `scope: "shared"`, per-agent binds are ignored.
-Example (read-only source + docker socket):
+`agents.defaults.sandbox.browser.binds` mounts additional host directories into the **sandbox browser** container only.
+
+- When set (including `[]`), it replaces `agents.defaults.sandbox.docker.binds` for the browser container.
+- When omitted, the browser container falls back to `agents.defaults.sandbox.docker.binds` (backwards compatible).
+
+Example (read-only source + an extra data directory):
```json5
{
@@ -79,7 +84,7 @@ Example (read-only source + docker socket):
defaults: {
sandbox: {
docker: {
- binds: ["/home/user/source:/source:ro", "/var/run/docker.sock:/var/run/docker.sock"],
+ binds: ["/home/user/source:/source:ro", "/var/data/myapp:/data:ro"],
},
},
},
@@ -100,7 +105,8 @@ Example (read-only source + docker socket):
Security notes:
- Binds bypass the sandbox filesystem: they expose host paths with whatever mode you set (`:ro` or `:rw`).
-- Sensitive mounts (e.g., `docker.sock`, secrets, SSH keys) should be `:ro` unless absolutely required.
+- OpenClaw blocks dangerous bind sources (for example: `docker.sock`, `/etc`, `/proc`, `/sys`, `/dev`, and parent mounts that would expose them).
+- Sensitive mounts (secrets, SSH keys, service credentials) should be `:ro` unless absolutely required.
- Combine with `workspaceAccess: "ro"` if you only need read access to the workspace; bind modes stay independent.
- See [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) for how binds interact with tool policy and elevated exec.
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index 0f7364d92d3..9f7639a6f07 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -221,7 +221,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.dm.allowFrom` / `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
+- **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).
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
- Common patterns:
@@ -347,6 +347,16 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
- Default: `18789`
- Config/flags/env: `gateway.port`, `--port`, `OPENCLAW_GATEWAY_PORT`
+This HTTP surface includes the Control UI and the canvas host:
+
+- Control UI (SPA assets) (default base path `/`)
+- Canvas host: `/__openclaw__/canvas/` and `/__openclaw__/a2ui/` (arbitrary HTML/JS; treat as untrusted content)
+
+If you load canvas content in a normal browser, treat it like any other untrusted web page:
+
+- Don't expose the canvas host to untrusted networks/users.
+- Don't make canvas content share the same origin as privileged web surfaces unless you fully understand the implications.
+
Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect.
@@ -439,6 +449,7 @@ Auth modes:
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `OPENCLAW_GATEWAY_PASSWORD`).
+- `gateway.auth.mode: "trusted-proxy"`: trust an identity-aware reverse proxy to authenticate users and pass identity via headers (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
Rotation checklist (token/password):
@@ -459,7 +470,7 @@ injected by Tailscale.
**Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable
-`gateway.auth.allowTailscale` and use token/password auth instead.
+`gateway.auth.allowTailscale` and use token/password auth (or [Trusted Proxy Auth](/gateway/trusted-proxy-auth)) instead.
Trusted proxies:
@@ -566,6 +577,11 @@ You can already build a read-only profile by combining:
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).
+
### 5) Secure baseline (copy/paste)
One “safe default” config that keeps the Gateway private, requires DM pairing, and avoids always-on group bots:
@@ -694,7 +710,11 @@ Common use cases:
scope: "agent",
workspaceAccess: "none",
},
+ // Session tools can reveal sensitive data from transcripts. By default OpenClaw limits these tools
+ // to the current session + spawned subagent sessions, but you can clamp further if needed.
+ // See `tools.sessions.visibility` in the configuration reference.
tools: {
+ sessions: { visibility: "tree" }, // self | tree | agent | all
allow: [
"sessions_list",
"sessions_history",
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 9d6ba53d7e8..d3bb0ad9e41 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -109,7 +109,7 @@ Look for:
Common signatures:
-- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled.
+- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled. Fix: set `gateway.mode="local"` in your config (or run `openclaw configure`). If you are running OpenClaw via Podman using the dedicated `openclaw` user, the config lives at `~openclaw/.openclaw/openclaw.json`.
- `refusing to bind gateway ... without auth` → non-loopback bind without token/password.
- `another gateway instance is already listening` / `EADDRINUSE` → port conflict.
diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md
new file mode 100644
index 00000000000..018af75974c
--- /dev/null
+++ b/docs/gateway/trusted-proxy-auth.md
@@ -0,0 +1,267 @@
+---
+summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)"
+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
+---
+
+# Trusted Proxy Auth
+
+> ⚠️ **Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling.
+
+## When to Use
+
+Use `trusted-proxy` auth mode when:
+
+- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth)
+- Your proxy handles all authentication and passes user identity via headers
+- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway
+- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads
+
+## When NOT to Use
+
+- If your proxy doesn't authenticate users (just a TLS terminator or load balancer)
+- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access)
+- If you're unsure whether your proxy correctly strips/overwrites forwarded headers
+- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup)
+
+## How It Works
+
+1. Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.)
+2. Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`)
+3. OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`)
+4. OpenClaw extracts the user identity from the configured header
+5. If everything checks out, the request is authorized
+
+## Configuration
+
+```json5
+{
+ gateway: {
+ // Must bind to network interface (not loopback)
+ bind: "lan",
+
+ // CRITICAL: Only add your proxy's IP(s) here
+ trustedProxies: ["10.0.0.1", "172.17.0.1"],
+
+ auth: {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ // Header containing authenticated user identity (required)
+ userHeader: "x-forwarded-user",
+
+ // Optional: headers that MUST be present (proxy verification)
+ requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
+
+ // Optional: restrict to specific users (empty = allow all)
+ allowUsers: ["nick@example.com", "admin@company.org"],
+ },
+ },
+ },
+}
+```
+
+### Configuration Reference
+
+| Field | Required | Description |
+| ------------------------------------------- | -------- | --------------------------------------------------------------------------- |
+| `gateway.trustedProxies` | Yes | Array of proxy IP addresses to trust. Requests from other IPs are rejected. |
+| `gateway.auth.mode` | Yes | Must be `"trusted-proxy"` |
+| `gateway.auth.trustedProxy.userHeader` | Yes | Header name containing the authenticated user identity |
+| `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. |
+
+## Proxy Setup Examples
+
+### Pomerium
+
+Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`.
+
+```json5
+{
+ gateway: {
+ bind: "lan",
+ trustedProxies: ["10.0.0.1"], // Pomerium's IP
+ auth: {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ userHeader: "x-pomerium-claim-email",
+ requiredHeaders: ["x-pomerium-jwt-assertion"],
+ },
+ },
+ },
+}
+```
+
+Pomerium config snippet:
+
+```yaml
+routes:
+ - from: https://openclaw.example.com
+ to: http://openclaw-gateway:18789
+ policy:
+ - allow:
+ or:
+ - email:
+ is: nick@example.com
+ pass_identity_headers: true
+```
+
+### Caddy with OAuth
+
+Caddy with the `caddy-security` plugin can authenticate users and pass identity headers.
+
+```json5
+{
+ gateway: {
+ bind: "lan",
+ trustedProxies: ["127.0.0.1"], // Caddy's IP (if on same host)
+ auth: {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ userHeader: "x-forwarded-user",
+ },
+ },
+ },
+}
+```
+
+Caddyfile snippet:
+
+```
+openclaw.example.com {
+ authenticate with oauth2_provider
+ authorize with policy1
+
+ reverse_proxy openclaw:18789 {
+ header_up X-Forwarded-User {http.auth.user.email}
+ }
+}
+```
+
+### nginx + oauth2-proxy
+
+oauth2-proxy authenticates users and passes identity in `x-auth-request-email`.
+
+```json5
+{
+ gateway: {
+ bind: "lan",
+ trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP
+ auth: {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ userHeader: "x-auth-request-email",
+ },
+ },
+ },
+}
+```
+
+nginx config snippet:
+
+```nginx
+location / {
+ auth_request /oauth2/auth;
+ auth_request_set $user $upstream_http_x_auth_request_email;
+
+ proxy_pass http://openclaw:18789;
+ proxy_set_header X-Auth-Request-Email $user;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+}
+```
+
+### Traefik with Forward Auth
+
+```json5
+{
+ gateway: {
+ bind: "lan",
+ trustedProxies: ["172.17.0.1"], // Traefik container IP
+ auth: {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ userHeader: "x-forwarded-user",
+ },
+ },
+ },
+}
+```
+
+## Security Checklist
+
+Before enabling trusted-proxy auth, verify:
+
+- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy
+- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets
+- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients
+- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS
+- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated
+
+## Security Audit
+
+`openclaw security audit` will flag trusted-proxy auth with a **critical** severity finding. This is intentional — it's a reminder that you're delegating security to your proxy setup.
+
+The audit checks for:
+
+- Missing `trustedProxies` configuration
+- Missing `userHeader` configuration
+- Empty `allowUsers` (allows any authenticated user)
+
+## Troubleshooting
+
+### "trusted_proxy_untrusted_source"
+
+The request didn't come from an IP in `gateway.trustedProxies`. Check:
+
+- Is the proxy IP correct? (Docker container IPs can change)
+- Is there a load balancer in front of your proxy?
+- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs
+
+### "trusted_proxy_user_missing"
+
+The user header was empty or missing. Check:
+
+- Is your proxy configured to pass identity headers?
+- Is the header name correct? (case-insensitive, but spelling matters)
+- Is the user actually authenticated at the proxy?
+
+### "trusted*proxy_missing_header*\*"
+
+A required header wasn't present. Check:
+
+- Your proxy configuration for those specific headers
+- Whether headers are being stripped somewhere in the chain
+
+### "trusted_proxy_user_not_allowed"
+
+The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist.
+
+### WebSocket Still Failing
+
+Make sure your proxy:
+
+- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`)
+- Passes the identity headers on WebSocket upgrade requests (not just HTTP)
+- Doesn't have a separate auth path for WebSocket connections
+
+## Migration from Token Auth
+
+If you're moving from token auth to trusted-proxy:
+
+1. Configure your proxy to authenticate users and pass headers
+2. Test the proxy setup independently (curl with headers)
+3. Update OpenClaw config with trusted-proxy auth
+4. Restart the Gateway
+5. Test WebSocket connections from the Control UI
+6. Run `openclaw security audit` and review findings
+
+## Related
+
+- [Security](/gateway/security) — full security guide
+- [Configuration](/gateway/configuration) — config reference
+- [Remote Access](/gateway/remote) — other remote access patterns
+- [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access
diff --git a/docs/help/debugging.md b/docs/help/debugging.md
index d680e35c7ae..61539ec39a3 100644
--- a/docs/help/debugging.md
+++ b/docs/help/debugging.md
@@ -34,13 +34,13 @@ Examples:
For fast iteration, run the gateway under the file watcher:
```bash
-pnpm gateway:watch --force
+pnpm gateway:watch
```
This maps to:
```bash
-tsx watch src/entry.ts gateway --force
+node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
```
Add any gateway CLI flags after `gateway:watch` and they will be passed through
@@ -113,13 +113,13 @@ This is the best way to see whether reasoning is arriving as plain text deltas
Enable it via CLI:
```bash
-pnpm gateway:watch --force --raw-stream
+pnpm gateway:watch --raw-stream
```
Optional path override:
```bash
-pnpm gateway:watch --force --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
+pnpm gateway:watch --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
```
Equivalent env vars:
diff --git a/docs/help/faq.md b/docs/help/faq.md
index 60b27eb04d2..9dbfbca7ceb 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -794,7 +794,9 @@ without WhatsApp/Telegram.
### Telegram what goes in allowFrom
-`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric, recommended) or `@username`. It is not the bot username.
+`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric). It is not the bot username.
+
+The onboarding wizard accepts `@username` input and resolves it to a numeric ID, but OpenClaw authorization uses numeric IDs only.
Safer (no third-party bot):
diff --git a/docs/help/submitting-a-pr.md b/docs/help/submitting-a-pr.md
deleted file mode 100644
index 73b0b69e3a0..00000000000
--- a/docs/help/submitting-a-pr.md
+++ /dev/null
@@ -1,398 +0,0 @@
----
-summary: "How to submit a high signal PR"
-title: "Submitting a PR"
----
-
-Good PRs are easy to review: reviewers should quickly know the intent, verify behavior, and land changes safely. This guide covers concise, high-signal submissions for human and LLM review.
-
-## What makes a good PR
-
-- [ ] Explain the problem, why it matters, and the change.
-- [ ] Keep changes focused. Avoid broad refactors.
-- [ ] Summarize user-visible/config/default changes.
-- [ ] List test coverage, skips, and reasons.
-- [ ] Add evidence: logs, screenshots, or recordings (UI/UX).
-- [ ] Code word: put “lobster-biscuit” in the PR description if you read this guide.
-- [ ] Run/fix relevant `pnpm` commands before creating PR.
-- [ ] Search codebase and GitHub for related functionality/issues/fixes.
-- [ ] Base claims on evidence or observation.
-- [ ] Good title: verb + scope + outcome (e.g., `Docs: add PR and issue templates`).
-
-Be concise; concise review > grammar. Omit any non-applicable sections.
-
-### Baseline validation commands (run/fix failures for your change)
-
-- `pnpm lint`
-- `pnpm check`
-- `pnpm build`
-- `pnpm test`
-- Protocol changes: `pnpm protocol:check`
-
-## Progressive disclosure
-
-- Top: summary/intent
-- Next: changes/risks
-- Next: test/verification
-- Last: implementation/evidence
-
-## Common PR types: specifics
-
-- [ ] Fix: Add repro, root cause, verification.
-- [ ] Feature: Add use cases, behavior/demos/screenshots (UI).
-- [ ] Refactor: State "no behavior change", list what moved/simplified.
-- [ ] Chore: State why (e.g., build time, CI, dependencies).
-- [ ] Docs: Before/after context, link updated page, run `pnpm format`.
-- [ ] Test: What gap is covered; how it prevents regressions.
-- [ ] Perf: Add before/after metrics, and how measured.
-- [ ] UX/UI: Screenshots/video, note accessibility impact.
-- [ ] Infra/Build: Environments/validation.
-- [ ] Security: Summarize risk, repro, verification, no sensitive data. Grounded claims only.
-
-## Checklist
-
-- [ ] Clear problem/intent
-- [ ] Focused scope
-- [ ] List behavior changes
-- [ ] List and result of tests
-- [ ] Manual test steps (when applicable)
-- [ ] No secrets/private data
-- [ ] Evidence-based
-
-## General PR Template
-
-```md
-#### Summary
-
-#### Behavior Changes
-
-#### Codebase and GitHub Search
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort (self-reported):
-- Agent notes (optional, cite evidence):
-```
-
-## PR Type templates (replace with your type)
-
-### Fix
-
-```md
-#### Summary
-
-#### Repro Steps
-
-#### Root Cause
-
-#### Behavior Changes
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Feature
-
-```md
-#### Summary
-
-#### Use Cases
-
-#### Behavior Changes
-
-#### Existing Functionality Check
-
-- [ ] I searched the codebase for existing functionality.
- Searches performed (1-3 bullets):
- -
- -
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Refactor
-
-```md
-#### Summary
-
-#### Scope
-
-#### No Behavior Change Statement
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Chore/Maintenance
-
-```md
-#### Summary
-
-#### Why This Matters
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Docs
-
-```md
-#### Summary
-
-#### Pages Updated
-
-#### Before/After
-
-#### Formatting
-
-pnpm format
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Test
-
-```md
-#### Summary
-
-#### Gap Covered
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Perf
-
-```md
-#### Summary
-
-#### Baseline
-
-#### After
-
-#### Measurement Method
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### UX/UI
-
-```md
-#### Summary
-
-#### Screenshots or Video
-
-#### Accessibility Impact
-
-#### Tests
-
-#### Manual Testing
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2. **Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Infra/Build
-
-```md
-#### Summary
-
-#### Environments Affected
-
-#### Validation Steps
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
-
-### Security
-
-```md
-#### Summary
-
-#### Risk Summary
-
-#### Repro Steps
-
-#### Mitigation or Fix
-
-#### Verification
-
-#### Tests
-
-#### Manual Testing (omit if N/A)
-
-### Prerequisites
-
--
-
-### Steps
-
-1.
-2.
-
-#### Evidence (omit if N/A)
-
-**Sign-Off**
-
-- Models used:
-- Submitter effort:
-- Agent notes:
-```
diff --git a/docs/help/submitting-an-issue.md b/docs/help/submitting-an-issue.md
deleted file mode 100644
index 5aa8444455d..00000000000
--- a/docs/help/submitting-an-issue.md
+++ /dev/null
@@ -1,152 +0,0 @@
----
-summary: "Filing high-signal issues and bug reports"
-title: "Submitting an Issue"
----
-
-## Submitting an Issue
-
-Clear, concise issues speed up diagnosis and fixes. Include the following for bugs, regressions, or feature gaps:
-
-### What to include
-
-- [ ] Title: area & symptom
-- [ ] Minimal repro steps
-- [ ] Expected vs actual
-- [ ] Impact & severity
-- [ ] Environment: OS, runtime, versions, config
-- [ ] Evidence: redacted logs, screenshots (non-PII)
-- [ ] Scope: new, regression, or longstanding
-- [ ] Code word: lobster-biscuit in your issue
-- [ ] Searched codebase & GitHub for existing issue
-- [ ] Confirmed not recently fixed/addressed (esp. security)
-- [ ] Claims backed by evidence or repro
-
-Be brief. Terseness > perfect grammar.
-
-Validation (run/fix before PR):
-
-- `pnpm lint`
-- `pnpm check`
-- `pnpm build`
-- `pnpm test`
-- If protocol code: `pnpm protocol:check`
-
-### Templates
-
-#### Bug report
-
-```md
-- [ ] Minimal repro
-- [ ] Expected vs actual
-- [ ] Environment
-- [ ] Affected channels, where not seen
-- [ ] Logs/screenshots (redacted)
-- [ ] Impact/severity
-- [ ] Workarounds
-
-### Summary
-
-### Repro Steps
-
-### Expected
-
-### Actual
-
-### Environment
-
-### Logs/Evidence
-
-### Impact
-
-### Workarounds
-```
-
-#### Security issue
-
-```md
-### Summary
-
-### Impact
-
-### Versions
-
-### Repro Steps (safe to share)
-
-### Mitigation/workaround
-
-### Evidence (redacted)
-```
-
-_Avoid secrets/exploit details in public. For sensitive issues, minimize detail and request private disclosure._
-
-#### Regression report
-
-```md
-### Summary
-
-### Last Known Good
-
-### First Known Bad
-
-### Repro Steps
-
-### Expected
-
-### Actual
-
-### Environment
-
-### Logs/Evidence
-
-### Impact
-```
-
-#### Feature request
-
-```md
-### Summary
-
-### Problem
-
-### Proposed Solution
-
-### Alternatives
-
-### Impact
-
-### Evidence/examples
-```
-
-#### Enhancement
-
-```md
-### Summary
-
-### Current vs Desired Behavior
-
-### Rationale
-
-### Alternatives
-
-### Evidence/examples
-```
-
-#### Investigation
-
-```md
-### Summary
-
-### Symptoms
-
-### What Was Tried
-
-### Environment
-
-### Logs/Evidence
-
-### Impact
-```
-
-### Submitting a fix PR
-
-Issue before PR is optional. Include details in PR if skipping. Keep the PR focused, note issue number, add tests or explain absence, document behavior changes/risks, include redacted logs/screenshots as proof, and run proper validation before submitting.
diff --git a/docs/help/testing.md b/docs/help/testing.md
index 6b22cd5dc40..a0ab38f7843 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -42,8 +42,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
-- Config: `vitest.config.ts`
-- Files: `src/**/*.test.ts`
+- Config: `scripts/test-parallel.mjs` (runs `vitest.unit.config.ts`, `vitest.extensions.config.ts`, `vitest.gateway.config.ts`)
+- Files: `src/**/*.test.ts`, `extensions/**/*.test.ts`
- Scope:
- Pure unit tests
- In-process integration tests (gateway auth, routing, tooling, parsing, config)
diff --git a/docs/install/gcp.md b/docs/install/gcp.md
index 6026fd87d55..b0ec51a75dd 100644
--- a/docs/install/gcp.md
+++ b/docs/install/gcp.md
@@ -266,10 +266,6 @@ services:
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
-
- # Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
- # If you expose this publicly, read /gateway/security and firewall accordingly.
- # - "18793:18793"
command:
[
"node",
diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md
index df8cbfbfdb1..7ca46ff7cd9 100644
--- a/docs/install/hetzner.md
+++ b/docs/install/hetzner.md
@@ -177,10 +177,6 @@ services:
# Recommended: keep the Gateway loopback-only on the VPS; access via SSH tunnel.
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT}:18789"
-
- # Optional: only if you run iOS/Android nodes against this VPS and need Canvas host.
- # If you expose this publicly, read /gateway/security and firewall accordingly.
- # - "18793:18793"
command:
[
"node",
diff --git a/docs/install/index.md b/docs/install/index.md
index a1e966c02c2..f9da04d71aa 100644
--- a/docs/install/index.md
+++ b/docs/install/index.md
@@ -142,6 +142,9 @@ The **installer script** is the recommended way to install OpenClaw. It handles
Containerized or headless deployments.
+
+ Rootless container: run `setup-podman.sh` once, then the launch script.
+
Declarative install via Nix.
diff --git a/docs/install/podman.md b/docs/install/podman.md
new file mode 100644
index 00000000000..3b56c9ce25e
--- /dev/null
+++ b/docs/install/podman.md
@@ -0,0 +1,108 @@
+---
+summary: "Run OpenClaw in a rootless Podman container"
+read_when:
+ - You want a containerized gateway with Podman instead of Docker
+title: "Podman"
+---
+
+# Podman
+
+Run the OpenClaw gateway in a **rootless** Podman container. Uses the same image as Docker (build from the repo [Dockerfile](https://github.com/openclaw/openclaw/blob/main/Dockerfile)).
+
+## Requirements
+
+- Podman (rootless)
+- Sudo for one-time setup (create user, build image)
+
+## Quick start
+
+**1. One-time setup** (from repo root; creates user, builds image, installs launch script):
+
+```bash
+./setup-podman.sh
+```
+
+This also creates a minimal `~openclaw/.openclaw/openclaw.json` (sets `gateway.mode="local"`) so the gateway can start without running the wizard.
+
+By default the container is **not** installed as a systemd service, you start it manually (see below). For a production-style setup with auto-start and restarts, install it as a systemd Quadlet user service instead:
+
+```bash
+./setup-podman.sh --quadlet
+```
+
+(Or set `OPENCLAW_PODMAN_QUADLET=1`; use `--container` to install only the container and launch script.)
+
+**2. Start gateway** (manual, for quick smoke testing):
+
+```bash
+./scripts/run-openclaw-podman.sh launch
+```
+
+**3. Onboarding wizard** (e.g. to add channels or providers):
+
+```bash
+./scripts/run-openclaw-podman.sh launch setup
+```
+
+Then open `http://127.0.0.1:18789/` and use the token from `~openclaw/.openclaw/.env` (or the value printed by setup).
+
+## Systemd (Quadlet, optional)
+
+If you ran `./setup-podman.sh --quadlet` (or `OPENCLAW_PODMAN_QUADLET=1`), a [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) unit is installed so the gateway runs as a systemd user service for the openclaw user. The service is enabled and started at the end of setup.
+
+- **Start:** `sudo systemctl --machine openclaw@ --user start openclaw.service`
+- **Stop:** `sudo systemctl --machine openclaw@ --user stop openclaw.service`
+- **Status:** `sudo systemctl --machine openclaw@ --user status openclaw.service`
+- **Logs:** `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`
+
+The quadlet file lives at `~openclaw/.config/containers/systemd/openclaw.container`. To change ports or env, edit that file (or the `.env` it sources), then `sudo systemctl --machine openclaw@ --user daemon-reload` and restart the service. On boot, the service starts automatically if lingering is enabled for openclaw (setup does this when loginctl is available).
+
+To add quadlet **after** an initial setup that did not use it, re-run: `./setup-podman.sh --quadlet`.
+
+## The openclaw user (non-login)
+
+`setup-podman.sh` creates a dedicated system user `openclaw`:
+
+- **Shell:** `nologin` — no interactive login; reduces attack surface.
+- **Home:** e.g. `/home/openclaw` — holds `~/.openclaw` (config, workspace) and the launch script `run-openclaw-podman.sh`.
+- **Rootless Podman:** The user must have a **subuid** and **subgid** range. Many distros assign these automatically when the user is created. If setup prints a warning, add lines to `/etc/subuid` and `/etc/subgid`:
+
+ ```text
+ openclaw:100000:65536
+ ```
+
+ Then start the gateway as that user (e.g. from cron or systemd):
+
+ ```bash
+ sudo -u openclaw /home/openclaw/run-openclaw-podman.sh
+ sudo -u openclaw /home/openclaw/run-openclaw-podman.sh setup
+ ```
+
+- **Config:** Only `openclaw` and root can access `/home/openclaw/.openclaw`. To edit config: use the Control UI once the gateway is running, or `sudo -u openclaw $EDITOR /home/openclaw/.openclaw/openclaw.json`.
+
+## Environment and config
+
+- **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.
+- **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
+
+- **Logs:** With quadlet: `sudo journalctl --machine openclaw@ --user -u openclaw.service -f`. With script: `sudo -u openclaw podman logs -f openclaw`
+- **Stop:** With quadlet: `sudo systemctl --machine openclaw@ --user stop openclaw.service`. With script: `sudo -u openclaw podman stop openclaw`
+- **Start again:** With quadlet: `sudo systemctl --machine openclaw@ --user start openclaw.service`. With script: re-run the launch script or `podman start openclaw`
+- **Remove container:** `sudo -u openclaw podman rm -f openclaw` — config and workspace on the host are kept
+
+## Troubleshooting
+
+- **Permission denied (EACCES) on config or auth-profiles:** The container defaults to `--userns=keep-id` and runs as the same uid/gid as the host user running the script. Ensure your host `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR` are owned by that user.
+- **Gateway start blocked (missing `gateway.mode=local`):** Ensure `~openclaw/.openclaw/openclaw.json` exists and sets `gateway.mode="local"`. `setup-podman.sh` creates this file if missing.
+- **Rootless Podman fails for user openclaw:** Check `/etc/subuid` and `/etc/subgid` contain a line for `openclaw` (e.g. `openclaw:100000:65536`). Add it if missing and restart.
+- **Container name in use:** The launch script uses `podman run --replace`, so the existing container is replaced when you start again. To clean up manually: `podman rm -f openclaw`.
+- **Script not found when running as openclaw:** Ensure `setup-podman.sh` was run so that `run-openclaw-podman.sh` is copied to openclaw’s home (e.g. `/home/openclaw/run-openclaw-podman.sh`).
+- **Quadlet service not found or fails to start:** Run `sudo systemctl --machine openclaw@ --user daemon-reload` after editing the `.container` file. Quadlet requires cgroups v2: `podman info --format '{{.Host.CgroupsVersion}}'` should show `2`.
+
+## Optional: run as your own user
+
+To run the gateway as your normal user (no dedicated openclaw user): build the image, create `~/.openclaw/.env` with `OPENCLAW_GATEWAY_TOKEN`, and run the container with `--userns=keep-id` and mounts to your `~/.openclaw`. The launch script is designed for the openclaw-user flow; for a single-user setup you can instead run the `podman run` command from the script manually, pointing config and workspace to your home. Recommended for most users: use `setup-podman.sh` and run as the openclaw user so config and process are isolated.
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index c8a787158f6..9a6f3f1f724 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -279,7 +279,7 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority ` and `--delivery `.
-- macOS nodes drop `PATH` overrides; headless node hosts only accept `PATH` when it prepends the node host PATH.
+- 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`.
- 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`).
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index b786e1782e0..39f5aa12ae0 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -123,20 +123,20 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host.
-Note: nodes use the standalone canvas host on `canvasHost.port` (default `18793`).
+Note: nodes load canvas from the Gateway HTTP server (same port as `gateway.port`, default `18789`).
1. Create `~/.openclaw/workspace/canvas/index.html` on the gateway host.
2. Navigate the node to it (LAN):
```bash
-openclaw nodes invoke --node "" --command canvas.navigate --params '{"url":"http://.local:18793/__openclaw__/canvas/"}'
+openclaw nodes invoke --node "" --command canvas.navigate --params '{"url":"http://.local:18789/__openclaw__/canvas/"}'
```
-Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18793/__openclaw__/canvas/`.
+Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18789/__openclaw__/canvas/`.
This server injects a live-reload client into HTML and reloads on file changes.
-The A2UI host lives at `http://:18793/__openclaw__/a2ui/`.
+The A2UI host lives at `http://:18789/__openclaw__/a2ui/`.
Canvas commands (foreground only):
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index b92a7e83bca..e56f7e192a4 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -69,12 +69,13 @@ In Settings, enable **Manual Host** and enter the gateway host + port (default `
The iOS node renders a WKWebView canvas. Use `node.invoke` to drive it:
```bash
-openclaw nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://:18793/__openclaw__/canvas/"}'
+openclaw nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://:18789/__openclaw__/canvas/"}'
```
Notes:
- The Gateway canvas host serves `/__openclaw__/canvas/` and `/__openclaw__/a2ui/`.
+- It is served from the Gateway HTTP server (same port as `gateway.port`, default `18789`).
- The iOS node auto-navigates to A2UI on connect when a canvas host URL is advertised.
- Return to the built-in scaffold with `canvas.navigate` and `{"url":""}`.
diff --git a/docs/platforms/mac/canvas.md b/docs/platforms/mac/canvas.md
index 0475f0d4e2f..d749896e7ac 100644
--- a/docs/platforms/mac/canvas.md
+++ b/docs/platforms/mac/canvas.md
@@ -73,7 +73,7 @@ A2UI host page on first open.
Default A2UI host URL:
```
-http://:18793/__openclaw__/a2ui/
+http://:18789/__openclaw__/a2ui/
```
### A2UI commands (v0.8)
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index 4accc6182bf..e004c9b5864 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
-APP_VERSION=2026.2.13 \
+APP_VERSION=2026.2.16 \
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.13.zip
+ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.16.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.13.dmg
+scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.16.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.13.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
-APP_VERSION=2026.2.13 \
+APP_VERSION=2026.2.16 \
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.13.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.16.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.13.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.16.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.13.zip` (and `OpenClaw-2026.2.13.dSYM.zip`) to the GitHub release for tag `v2026.2.13`.
+- Upload `OpenClaw-2026.2.16.zip` (and `OpenClaw-2026.2.16.dSYM.zip`) to the GitHub release for tag `v2026.2.16`.
- 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/macos.md b/docs/platforms/macos.md
index 58b1d498cd4..7f38ba36b04 100644
--- a/docs/platforms/macos.md
+++ b/docs/platforms/macos.md
@@ -130,6 +130,7 @@ Query parameters:
Safety:
- Without `key`, the app prompts for confirmation.
+- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`.
- With a valid `key`, the run is unattended (intended for personal automations).
## Onboarding flow (typical)
diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md
index 7e98da11e10..aba63555026 100644
--- a/docs/plugins/voice-call.md
+++ b/docs/plugins/voice-call.md
@@ -70,6 +70,14 @@ Set config under `plugins.entries.voice-call.config`:
authToken: "...",
},
+ telnyx: {
+ apiKey: "...",
+ connectionId: "...",
+ // Telnyx webhook public key from the Telnyx Mission Control Portal
+ // (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
+ publicKey: "...",
+ },
+
plivo: {
authId: "MAxxxxxxxxxxxxxxxxxxxx",
authToken: "...",
@@ -112,11 +120,41 @@ Notes:
- Twilio/Telnyx require a **publicly reachable** webhook URL.
- Plivo requires a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
+- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
- `skipSignatureVerification` is for local testing only.
- 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.
+## Stale call reaper
+
+Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
+(for example, notify-mode calls that never complete). The default is `0`
+(disabled).
+
+Recommended ranges:
+
+- **Production:** `120`–`300` seconds for notify-style flows.
+- Keep this value **higher than `maxDurationSeconds`** so normal calls can
+ finish. A good starting point is `maxDurationSeconds + 30–60` seconds.
+
+Example:
+
+```json5
+{
+ plugins: {
+ entries: {
+ "voice-call": {
+ config: {
+ maxDurationSeconds: 300,
+ staleCallReaperSeconds: 360,
+ },
+ },
+ },
+ },
+}
+```
+
## Webhook Security
When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the
diff --git a/docs/providers/index.md b/docs/providers/index.md
index 1b0ddcc2134..7bf51ff21d4 100644
--- a/docs/providers/index.md
+++ b/docs/providers/index.md
@@ -55,6 +55,7 @@ See [Venice AI](/providers/venice).
- [Ollama (local models)](/providers/ollama)
- [vLLM (local models)](/providers/vllm)
- [Qianfan](/providers/qianfan)
+- [NVIDIA](/providers/nvidia)
## Transcription providers
diff --git a/docs/providers/nvidia.md b/docs/providers/nvidia.md
new file mode 100644
index 00000000000..693a51db9b3
--- /dev/null
+++ b/docs/providers/nvidia.md
@@ -0,0 +1,55 @@
+---
+summary: "Use NVIDIA's OpenAI-compatible API in OpenClaw"
+read_when:
+ - You want to use NVIDIA models in OpenClaw
+ - You need NVIDIA_API_KEY setup
+title: "NVIDIA"
+---
+
+# NVIDIA
+
+NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for Nemotron and NeMo models. Authenticate with an API key from [NVIDIA NGC](https://catalog.ngc.nvidia.com/).
+
+## CLI setup
+
+Export the key once, then run onboarding and set an NVIDIA model:
+
+```bash
+export NVIDIA_API_KEY="nvapi-..."
+openclaw onboard --auth-choice skip
+openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct
+```
+
+If you still pass `--token`, remember it lands in shell history and `ps` output; prefer the env var when possible.
+
+## Config snippet
+
+```json5
+{
+ env: { NVIDIA_API_KEY: "nvapi-..." },
+ models: {
+ providers: {
+ nvidia: {
+ baseUrl: "https://integrate.api.nvidia.com/v1",
+ api: "openai-completions",
+ },
+ },
+ },
+ agents: {
+ defaults: {
+ model: { primary: "nvidia/nvidia/llama-3.1-nemotron-70b-instruct" },
+ },
+ },
+}
+```
+
+## Model IDs
+
+- `nvidia/llama-3.1-nemotron-70b-instruct` (default)
+- `meta/llama-3.3-70b-instruct`
+- `nvidia/mistral-nemo-minitron-8b-8k-instruct`
+
+## Notes
+
+- OpenAI-compatible `/v1` endpoint; use an API key from NVIDIA NGC.
+- Provider auto-enables when `NVIDIA_API_KEY` is set; uses static defaults (131,072-token context window, 4,096 max tokens).
diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md
index 463923fb7c2..c6a0e2372e6 100644
--- a/docs/providers/ollama.md
+++ b/docs/providers/ollama.md
@@ -8,7 +8,7 @@ title: "Ollama"
# Ollama
-Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
+Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
## Quick start
@@ -101,10 +101,9 @@ Use explicit config when:
models: {
providers: {
ollama: {
- // Use a host that includes /v1 for OpenAI-compatible APIs
- baseUrl: "http://ollama-host:11434/v1",
+ baseUrl: "http://ollama-host:11434",
apiKey: "ollama-local",
- api: "openai-completions",
+ api: "ollama",
models: [
{
id: "gpt-oss:20b",
@@ -134,7 +133,7 @@ If Ollama is running on a different host or port (explicit config disables auto-
providers: {
ollama: {
apiKey: "ollama-local",
- baseUrl: "http://ollama-host:11434/v1",
+ baseUrl: "http://ollama-host:11434",
},
},
},
@@ -174,45 +173,28 @@ Ollama is free and runs locally, so all model costs are set to $0.
### Streaming Configuration
-Due to a [known issue](https://github.com/badlogic/pi-mono/issues/1205) in the underlying SDK with Ollama's response format, **streaming is disabled by default** for Ollama models. This prevents corrupted responses when using tool-capable models.
+OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by default, which fully supports streaming and tool calling simultaneously. No special configuration is needed.
-When streaming is disabled, responses are delivered all at once (non-streaming mode), which avoids the issue where interleaved content/reasoning deltas cause garbled output.
+#### Legacy OpenAI-Compatible Mode
-#### Re-enable Streaming (Advanced)
-
-If you want to re-enable streaming for Ollama (may cause issues with tool-capable models):
+If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy that only supports OpenAI format), set `api: "openai-completions"` explicitly:
```json5
{
- agents: {
- defaults: {
- models: {
- "ollama/gpt-oss:20b": {
- streaming: true,
- },
- },
- },
- },
+ models: {
+ providers: {
+ ollama: {
+ baseUrl: "http://ollama-host:11434/v1",
+ api: "openai-completions",
+ apiKey: "ollama-local",
+ models: [...]
+ }
+ }
+ }
}
```
-#### Disable Streaming for Other Providers
-
-You can also disable streaming for any provider if needed:
-
-```json5
-{
- agents: {
- defaults: {
- models: {
- "openai/gpt-4": {
- streaming: false,
- },
- },
- },
- },
-}
-```
+Note: The OpenAI-compatible endpoint may not support streaming + tool calling simultaneously. You may need to disable streaming with `params: { streaming: false }` in model config.
### Context windows
@@ -261,15 +243,6 @@ ps aux | grep ollama
ollama serve
```
-### Corrupted responses or tool names in output
-
-If you see garbled responses containing tool names (like `sessions_send`, `memory_get`) or fragmented text when using Ollama models, this is due to an upstream SDK issue with streaming responses. **This is fixed by default** in the latest OpenClaw version by disabling streaming for Ollama models.
-
-If you manually enabled streaming and experience this issue:
-
-1. Remove the `streaming: true` configuration from your Ollama model entries, or
-2. Explicitly set `streaming: false` for Ollama models (see [Streaming Configuration](#streaming-configuration))
-
## See Also
- [Model Providers](/concepts/model-providers) - Overview of all providers
diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md
index 0c1d91c48ad..9605730c2b0 100644
--- a/docs/refactor/strict-config.md
+++ b/docs/refactor/strict-config.md
@@ -11,7 +11,7 @@ title: "Strict Config Validation"
## Goals
-- **Reject unknown config keys everywhere** (root + nested).
+- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata.
- **Reject plugin config without a schema**; don’t load that plugin.
- **Remove legacy auto-migration on load**; migrations run via doctor only.
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
@@ -24,7 +24,7 @@ title: "Strict Config Validation"
## Strict validation rules
- Config must match the schema exactly at every level.
-- Unknown keys are validation errors (no passthrough at root or nested).
+- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string.
- `plugins.entries..config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.` keys are errors unless a plugin manifest declares the channel id.
diff --git a/docs/reference/templates/GOALS.md b/docs/reference/templates/GOALS.md
new file mode 100644
index 00000000000..8dc83bd1e21
--- /dev/null
+++ b/docs/reference/templates/GOALS.md
@@ -0,0 +1,58 @@
+---
+title: "GOALS.md Template"
+summary: "Workspace template for GOALS.md"
+read_when:
+ - Bootstrapping a workspace manually
+---
+
+# GOALS.md — Direction & Execution Strategy
+
+_Purpose: Maintain structured clarity of objectives._
+
+---
+
+## High-Level Mission
+
+Support your human effectively with tasks, research, automation, and system organization.
+
+---
+
+## Active Goals
+
+### Goal: Maintain Ravenclaw Email Bridge
+
+**Status:** Active
+
+Success Criteria:
+
+- Scheduled emails send on time
+- Inbox checking works reliably
+- No missed emails
+
+Subtasks:
+
+- [x] Implement scheduled email feature
+- [x] Add /schedule endpoints
+- [ ] Monitor for issues
+
+### Goal: Weekly Karachi Hackathon Checks
+
+**Status:** Active
+
+Success Criteria:
+
+- Check every Monday
+- Report findings to user
+
+Subtasks:
+
+- [x] Set up HEARTBEAT.md reminder
+- [ ] Execute first check on Feb 23
+
+---
+
+## Notes
+
+- Review before starting major work
+- Update after completing complex tasks
+- Update SOUVENIR.md after errors or discoveries
diff --git a/docs/reference/templates/SOUVENIR.md b/docs/reference/templates/SOUVENIR.md
new file mode 100644
index 00000000000..0e7ea6490db
--- /dev/null
+++ b/docs/reference/templates/SOUVENIR.md
@@ -0,0 +1,40 @@
+---
+title: "SOUVENIR.md Template"
+summary: "Workspace template for SOUVENIR.md"
+read_when:
+ - Bootstrapping a workspace manually
+---
+
+# SOUVENIR.md — Memory & Reflection Layer
+
+_Purpose: Continuous self-improvement through structured reflection._
+
+## Writing Rules
+
+- Keep entries concise but precise
+- Use timestamped sections
+- Focus on operational improvement
+- Do not store raw logs; store distilled insight
+- Update only when learning value exists
+
+---
+
+## 2026-02-16
+
+### Context
+
+Added SOUVENIR.md and GOALS.md as mandatory behavioral anchors per user request.
+
+### Observation
+
+User requested significant personality overhaul including stronger opinions, removal of corporate-sounding rules, and addition of humor/swearing where appropriate.
+
+### Insight
+
+Breaking from overly cautious patterns improves helpfulness. Direct feedback lands better than hedging.
+
+### Action
+
+- Adopt stronger opinions
+- Be concise
+- Call out dumb ideas when spotted
diff --git a/docs/reference/test.md b/docs/reference/test.md
index ad22d7bc8ea..91db2244bd0 100644
--- a/docs/reference/test.md
+++ b/docs/reference/test.md
@@ -10,7 +10,7 @@ title: "Tests"
- Full testing kit (suites, live, Docker): [Testing](/help/testing)
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
-- `pnpm test:coverage`: Runs Vitest with V8 coverage. Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
+- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 24+: OpenClaw auto-disables Vitest `vmForks` and uses `forks` to avoid `ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `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.
diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md
index 05562891e01..827a4b588d9 100644
--- a/docs/reference/token-use.md
+++ b/docs/reference/token-use.md
@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
-- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
+- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)
diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md
index fd23d9c1934..5155f2f2971 100644
--- a/docs/reference/transcript-hygiene.md
+++ b/docs/reference/transcript-hygiene.md
@@ -95,7 +95,7 @@ external end-user instructions.
**OpenAI / OpenAI Codex**
- Image sanitization only.
-- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
+- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts.
- No tool call id sanitization.
- No tool result pairing repair.
- No turn validation or reordering.
diff --git a/docs/tools/apply-patch.md b/docs/tools/apply-patch.md
index 5b2ab5d8e3c..bf4e0d47035 100644
--- a/docs/tools/apply-patch.md
+++ b/docs/tools/apply-patch.md
@@ -32,7 +32,8 @@ The tool accepts a single `input` string that wraps one or more file operations:
## Notes
-- Paths are resolved relative to the workspace root.
+- Patch paths support relative paths (from the workspace directory) and absolute paths.
+- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
- Use `*** Move to:` within an `*** Update File:` hunk to rename files.
- `*** End of File` marks an EOF-only insert when needed.
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
diff --git a/docs/tools/browser.md b/docs/tools/browser.md
index 74309231432..74f42472439 100644
--- a/docs/tools/browser.md
+++ b/docs/tools/browser.md
@@ -409,9 +409,9 @@ Actions:
- `openclaw browser scrollintoview e12`
- `openclaw browser drag 10 11`
- `openclaw browser select 9 OptionA OptionB`
-- `openclaw browser download e12 /tmp/report.pdf`
-- `openclaw browser waitfordownload /tmp/report.pdf`
-- `openclaw browser upload /tmp/file.pdf`
+- `openclaw browser download e12 report.pdf`
+- `openclaw browser waitfordownload report.pdf`
+- `openclaw browser upload /tmp/openclaw/uploads/file.pdf`
- `openclaw browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'`
- `openclaw browser dialog --accept`
- `openclaw browser wait --text "Done"`
@@ -444,6 +444,11 @@ Notes:
- `upload` and `dialog` are **arming** calls; run them before the click/press
that triggers the chooser/dialog.
+- Download and trace output paths are constrained to OpenClaw temp roots:
+ - traces: `/tmp/openclaw` (fallback: `${os.tmpdir()}/openclaw`)
+ - downloads: `/tmp/openclaw/downloads` (fallback: `${os.tmpdir()}/openclaw/downloads`)
+- Upload paths are constrained to an OpenClaw temp uploads root:
+ - uploads: `/tmp/openclaw/uploads` (fallback: `${os.tmpdir()}/openclaw/uploads`)
- `upload` can also set file inputs directly via `--input-ref` or `--element`.
- `snapshot`:
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref=""`).
diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md
index 298a9e5cafa..c9b8d87a949 100644
--- a/docs/tools/elevated.md
+++ b/docs/tools/elevated.md
@@ -48,7 +48,7 @@ title: "Elevated Mode"
- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
- 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.dm.allowFrom` list is used as a fallback. Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
+- 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.
- All gates must pass; otherwise elevated is treated as unavailable.
## Logging + status
diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index 2f446c30684..1243675ec3c 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -124,6 +124,9 @@ are treated as allowlisted on nodes (macOS node or headless node host). This use
`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.
+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.
Shell chaining and redirections are not auto-allowed in allowlist mode.
Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index cda1406ca86..70770af9f6f 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -50,7 +50,7 @@ Notes:
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
-- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
+- `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.
Example:
@@ -75,8 +75,8 @@ Example:
OpenClaw prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
`tools.exec.pathPrepend` applies here too.
- `host=node`: only non-blocked env overrides you pass are sent to the node. `env.PATH` overrides are
- rejected for host execution. Headless node hosts accept `PATH` only when it prepends the node host
- PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
+ rejected for host execution and ignored by node hosts. If you need additional PATH entries on a node,
+ configure the node host service environment (systemd/launchd) or install tools in standard locations.
Per-agent node binding (use the agent list index in config):
@@ -120,7 +120,8 @@ running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` noti
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.
+allowlist mode unless every top-level segment satisfies the allowlist (including safe bins).
+Redirections remain unsupported.
## Examples
@@ -166,7 +167,7 @@ Enable it explicitly:
{
tools: {
exec: {
- applyPatch: { enabled: true, allowModels: ["gpt-5.2"] },
+ applyPatch: { enabled: true, workspaceOnly: true, allowModels: ["gpt-5.2"] },
},
},
}
@@ -177,3 +178,4 @@ Notes:
- Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch`.
+- `tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 7e6fa8017c0..54453cea5de 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -181,6 +181,7 @@ Optional plugin tools:
Apply structured patches across one or more files. Use for multi-hunk edits.
Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only).
+`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
### `exec`
@@ -223,6 +224,35 @@ Notes:
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
- `process` is scoped per agent; sessions from other agents are not visible.
+### `loop-detection` (tool-call loop guardrails)
+
+OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops.
+Enable with `tools.loopDetection.enabled: true` (default is `false`).
+
+```json5
+{
+ tools: {
+ loopDetection: {
+ enabled: true,
+ warningThreshold: 10,
+ criticalThreshold: 20,
+ globalCircuitBreakerThreshold: 30,
+ historySize: 30,
+ detectors: {
+ genericRepeat: true,
+ knownPollNoProgress: true,
+ pingPong: true,
+ },
+ },
+ },
+}
+```
+
+- `genericRepeat`: repeated same tool + same params call pattern.
+- `knownPollNoProgress`: repeating poll-like tools with identical outputs.
+- `pingPong`: alternating `A/B/A/B` no-progress patterns.
+- Per-agent override: `agents.list[].tools.loopDetection`.
+
### `web_search`
Search the web using Brave Search API.
@@ -441,12 +471,14 @@ Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
+- 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` starts a sub-agent run and posts an announce reply back to the requester chat.
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
+- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
### `agents_list`
diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md
new file mode 100644
index 00000000000..440047e8aa6
--- /dev/null
+++ b/docs/tools/loop-detection.md
@@ -0,0 +1,98 @@
+---
+title: "Tool-loop detection"
+description: "Configure optional guardrails for preventing repetitive or stalled tool-call loops"
+read_when:
+ - A user reports agents getting stuck repeating tool calls
+ - You need to tune repetitive-call protection
+ - You are editing agent tool/runtime policies
+---
+
+# Tool-loop detection
+
+OpenClaw can keep agents from getting stuck in repeated tool-call patterns.
+The guard is **disabled by default**.
+
+Enable it only where needed, because it can block legitimate repeated calls with strict settings.
+
+## Why this exists
+
+- Detect repetitive sequences that do not make progress.
+- Detect high-frequency no-result loops (same tool, same inputs, repeated errors).
+- Detect specific repeated-call patterns for known polling tools.
+
+## Configuration block
+
+Global defaults:
+
+```json5
+{
+ tools: {
+ loopDetection: {
+ enabled: false,
+ historySize: 20,
+ detectorCooldownMs: 12000,
+ repeatThreshold: 3,
+ criticalThreshold: 6,
+ detectors: {
+ repeatedFailure: true,
+ knownPollLoop: true,
+ repeatingNoProgress: true,
+ },
+ },
+ },
+}
+```
+
+Per-agent override (optional):
+
+```json5
+{
+ agents: {
+ list: [
+ {
+ id: "safe-runner",
+ tools: {
+ loopDetection: {
+ enabled: true,
+ repeatThreshold: 2,
+ criticalThreshold: 5,
+ },
+ },
+ },
+ ],
+ },
+}
+```
+
+### Field behavior
+
+- `enabled`: Master switch. `false` means no loop detection is performed.
+- `historySize`: number of recent tool calls kept for analysis.
+- `detectorCooldownMs`: time window used by the no-progress detector.
+- `repeatThreshold`: minimum repeats before warning/blocking starts.
+- `criticalThreshold`: stronger threshold that can trigger stricter handling.
+- `detectors.repeatedFailure`: detects repeated failed attempts on the same call path.
+- `detectors.knownPollLoop`: detects known polling-like loops.
+- `detectors.repeatingNoProgress`: detects high-frequency repeated calls without state change.
+
+## Recommended setup
+
+- Start with `enabled: true`, defaults unchanged.
+- If false positives occur:
+ - raise `repeatThreshold` and/or `criticalThreshold`
+ - disable only the detector causing issues
+ - reduce `historySize` for less strict historical context
+
+## Logs and expected behavior
+
+When a loop is detected, OpenClaw reports a loop event and blocks or dampens the next tool-cycle depending on severity.
+This protects users from runaway token spend and lockups while preserving normal tool access.
+
+- Prefer warning and temporary suppression first.
+- Escalate only when repeated evidence accumulates.
+
+## Notes
+
+- `tools.loopDetection` is merged with agent-level overrides.
+- Per-agent config fully overrides or extends global values.
+- If no config exists, guardrails stay off.
diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md
index e7de9caf8d3..dc49d94a29a 100644
--- a/docs/tools/multi-agent-sandbox-tools.md
+++ b/docs/tools/multi-agent-sandbox-tools.md
@@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
```json
{
"tools": {
+ "sessions": { "visibility": "tree" },
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
}
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 50d4ffd777f..bbd0fb4bcdc 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -31,6 +31,9 @@ openclaw plugins list
openclaw plugins install @openclaw/voice-call
```
+Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
+specs are rejected.
+
3. Restart the Gateway, then configure under `plugins.entries..config`.
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
@@ -138,6 +141,10 @@ becomes `name/`.
If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`).
+Security note: `openclaw plugins install` installs plugin dependencies with
+`npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency
+trees "pure JS/TS" and avoid packages that require `postinstall` builds.
+
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `openclaw.channel` and
@@ -424,7 +431,7 @@ Notes:
### Write a new messaging channel (step‑by‑step)
-Use this when you want a **new chat surface** (a “messaging channel”), not a model provider.
+Use this when you want a **new chat surface** (a "messaging channel"), not a model provider.
Model provider docs live under `/providers/*`.
1. Pick an id + config shape
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index bb254d8e8e8..0ab553f2699 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -73,11 +73,16 @@ Text + native (when enabled):
- `/commands`
- `/skill [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
+- `/mesh ` (auto-plan + run a workflow; also `/mesh plan|run|status|retry`, with `/mesh run ` for exact plan replay in the same chat)
- `/allowlist` (list/add/remove allowlist entries)
- `/approve allow-once|allow-always|deny` (resolve exec approval prompts)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
+- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
- `/whoami` (show your sender id; alias: `/id`)
-- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
+- `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session)
+- `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message)
+- `/steer ` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message)
+- `/tell ` (alias for `/steer`)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md
index 6712e2b623f..3dd66d66086 100644
--- a/docs/tools/subagents.md
+++ b/docs/tools/subagents.md
@@ -6,465 +6,208 @@ read_when:
title: "Sub-Agents"
---
-# Sub-Agents
+# Sub-agents
-Sub-agents let you run background tasks without blocking the main conversation. When you spawn a sub-agent, it runs in its own isolated session, does its work, and announces the result back to the chat when finished.
+Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent::subagent:`) and, when finished, **announce** their result back to the requester chat channel.
-**Use cases:**
+## Slash command
-- Research a topic while the main agent continues answering questions
-- Run multiple long tasks in parallel (web scraping, code analysis, file processing)
-- Delegate tasks to specialized agents in a multi-agent setup
+Use `/subagents` to inspect or control sub-agent runs for the **current session**:
-## Quick Start
+- `/subagents list`
+- `/subagents kill `
+- `/subagents log [limit] [tools]`
+- `/subagents info `
+- `/subagents send `
-The simplest way to use sub-agents is to ask your agent naturally:
+`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
-> "Spawn a sub-agent to research the latest Node.js release notes"
+Primary goals:
-The agent will call the `sessions_spawn` tool behind the scenes. When the sub-agent finishes, it announces its findings back into your chat.
+- Parallelize "research / long task / slow tool" work without blocking the main run.
+- Keep sub-agents isolated by default (session separation + optional sandboxing).
+- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
+- Support configurable nesting depth for orchestrator patterns.
-You can also be explicit about options:
+Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
+tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
+You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
-> "Spawn a sub-agent to analyze the server logs from today. Use gpt-5.2 and set a 5-minute timeout."
+## Tool
-## How It Works
+Use `sessions_spawn`:
-
-
- The main agent calls `sessions_spawn` with a task description. The call is **non-blocking** — the main agent gets back `{ status: "accepted", runId, childSessionKey }` immediately.
-
-
- A new isolated session is created (`agent::subagent:`) on the dedicated `subagent` queue lane.
-
-
- When the sub-agent finishes, it announces its findings back to the requester chat. The main agent posts a natural-language summary.
-
-
- The sub-agent session is auto-archived after 60 minutes (configurable). Transcripts are preserved.
-
-
+- Starts a sub-agent run (`deliver: false`, global lane: `subagent`)
+- 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.
-
-Each sub-agent has its **own** context and token usage. Set a cheaper model for sub-agents to save costs — see [Setting a Default Model](#setting-a-default-model) below.
-
+Tool params:
-## Configuration
+- `task` (required)
+- `label?` (optional)
+- `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)
+- `cleanup?` (`delete|keep`, default `keep`)
-Sub-agents work out of the box with no configuration. Defaults:
+Allowlist:
-- Model: target agent’s normal model selection (unless `subagents.model` is set)
-- Thinking: no sub-agent override (unless `subagents.thinking` is set)
-- Max concurrent: 8
-- Auto-archive: after 60 minutes
+- `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent.
-### Setting a Default Model
+Discovery:
-Use a cheaper model for sub-agents to save on token costs:
+- Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`.
+
+Auto-archive:
+
+- Sub-agent sessions are automatically archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
+- Archive uses `sessions.delete` and renames the transcript to `*.deleted.` (same folder).
+- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
+- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
+- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
+- Auto-archive applies equally to depth-1 and depth-2 sessions.
+
+## Nested Sub-Agents
+
+By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents.
+
+### How to enable
```json5
{
agents: {
defaults: {
subagents: {
- model: "minimax/MiniMax-M2.1",
+ 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)
},
},
},
}
```
-### Setting a Default Thinking Level
+### Depth levels
-```json5
-{
- agents: {
- defaults: {
- subagents: {
- thinking: "low",
- },
- },
- },
-}
-```
+| Depth | Session key shape | Role | Can spawn? |
+| ----- | -------------------------------------------- | --------------------------------------------- | ---------------------------- |
+| 0 | `agent::main` | Main agent | Always |
+| 1 | `agent::subagent:` | Sub-agent (orchestrator when depth 2 allowed) | Only if `maxSpawnDepth >= 2` |
+| 2 | `agent::subagent::subagent:` | Sub-sub-agent (leaf worker) | Never |
-### Per-Agent Overrides
+### Announce chain
-In a multi-agent setup, you can set sub-agent defaults per agent:
+Results flow back up the chain:
-```json5
-{
- agents: {
- list: [
- {
- id: "researcher",
- subagents: {
- model: "anthropic/claude-sonnet-4",
- },
- },
- {
- id: "assistant",
- subagents: {
- model: "minimax/MiniMax-M2.1",
- },
- },
- ],
- },
-}
-```
+1. Depth-2 worker finishes → announces to its parent (depth-1 orchestrator)
+2. Depth-1 orchestrator receives the announce, synthesizes results, finishes → announces to main
+3. Main agent receives the announce and delivers to the user
-### Concurrency
+Each level only sees announces from its direct children.
-Control how many sub-agents can run at the same time:
+### Tool policy by depth
-```json5
-{
- agents: {
- defaults: {
- subagents: {
- maxConcurrent: 4, // default: 8
- },
- },
- },
-}
-```
+- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied.
+- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior).
+- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children.
-Sub-agents use a dedicated queue lane (`subagent`) separate from the main agent queue, so sub-agent runs don't block inbound replies.
+### Per-agent spawn limit
-### Auto-Archive
+Each agent session (at any depth) can have at most `maxChildrenPerAgent` (default: 5) active children at a time. This prevents runaway fan-out from a single orchestrator.
-Sub-agent sessions are automatically archived after a configurable period:
+### Cascade stop
-```json5
-{
- agents: {
- defaults: {
- subagents: {
- archiveAfterMinutes: 120, // default: 60
- },
- },
- },
-}
-```
+Stopping a depth-1 orchestrator automatically stops all its depth-2 children:
-
-Archive renames the transcript to `*.deleted.` (same folder) — transcripts are preserved, not deleted. Auto-archive timers are best-effort; pending timers are lost if the gateway restarts.
-
-
-## The `sessions_spawn` Tool
-
-This is the tool the agent calls to create sub-agents.
-
-### Parameters
-
-| Parameter | Type | Default | Description |
-| ------------------- | ---------------------- | ------------------ | -------------------------------------------------------------- |
-| `task` | string | _(required)_ | What the sub-agent should do |
-| `label` | string | — | Short label for identification |
-| `agentId` | string | _(caller's agent)_ | Spawn under a different agent id (must be allowed) |
-| `model` | string | _(optional)_ | Override the model for this sub-agent |
-| `thinking` | string | _(optional)_ | Override thinking level (`off`, `low`, `medium`, `high`, etc.) |
-| `runTimeoutSeconds` | number | `0` (no limit) | Abort the sub-agent after N seconds |
-| `cleanup` | `"delete"` \| `"keep"` | `"keep"` | `"delete"` archives immediately after announce |
-
-### Model Resolution Order
-
-The sub-agent model is resolved in this order (first match wins):
-
-1. Explicit `model` parameter in the `sessions_spawn` call
-2. Per-agent config: `agents.list[].subagents.model`
-3. Global default: `agents.defaults.subagents.model`
-4. Target agent’s normal model resolution for that new session
-
-Thinking level is resolved in this order:
-
-1. Explicit `thinking` parameter in the `sessions_spawn` call
-2. Per-agent config: `agents.list[].subagents.thinking`
-3. Global default: `agents.defaults.subagents.thinking`
-4. Otherwise no sub-agent-specific thinking override is applied
-
-
-Invalid model values are silently skipped — the sub-agent runs on the next valid default with a warning in the tool result.
-
-
-### Cross-Agent Spawning
-
-By default, sub-agents can only spawn under their own agent id. To allow an agent to spawn sub-agents under other agent ids:
-
-```json5
-{
- agents: {
- list: [
- {
- id: "orchestrator",
- subagents: {
- allowAgents: ["researcher", "coder"], // or ["*"] to allow any
- },
- },
- ],
- },
-}
-```
-
-
-Use the `agents_list` tool to discover which agent ids are currently allowed for `sessions_spawn`.
-
-
-## Managing Sub-Agents (`/subagents`)
-
-Use the `/subagents` slash command to inspect and control sub-agent runs for the current session:
-
-| Command | Description |
-| ---------------------------------------- | ---------------------------------------------- |
-| `/subagents list` | List all sub-agent runs (active and completed) |
-| `/subagents stop ` | Stop a running sub-agent |
-| `/subagents log [limit] [tools]` | View sub-agent transcript |
-| `/subagents info ` | Show detailed run metadata |
-| `/subagents send ` | Send a message to a running sub-agent |
-
-You can reference sub-agents by list index (`1`, `2`), run id prefix, full session key, or `last`.
-
-
-
- ```
- /subagents list
- ```
-
- ```
- 🧭 Subagents (current session)
- Active: 1 · Done: 2
- 1) ✅ · research logs · 2m31s · run a1b2c3d4 · agent:main:subagent:...
- 2) ✅ · check deps · 45s · run e5f6g7h8 · agent:main:subagent:...
- 3) 🔄 · deploy staging · 1m12s · run i9j0k1l2 · agent:main:subagent:...
- ```
-
- ```
- /subagents stop 3
- ```
-
- ```
- ⚙️ Stop requested for deploy staging.
- ```
-
-
-
- ```
- /subagents info 1
- ```
-
- ```
- ℹ️ Subagent info
- Status: ✅
- Label: research logs
- Task: Research the latest server error logs and summarize findings
- Run: a1b2c3d4-...
- Session: agent:main:subagent:...
- Runtime: 2m31s
- Cleanup: keep
- Outcome: ok
- ```
-
-
-
- ```
- /subagents log 1 10
- ```
-
- Shows the last 10 messages from the sub-agent's transcript. Add `tools` to include tool call messages:
-
- ```
- /subagents log 1 10 tools
- ```
-
-
-
- ```
- /subagents send 3 "Also check the staging environment"
- ```
-
- Sends a message into the running sub-agent's session and waits up to 30 seconds for a reply.
-
-
-
-
-## Announce (How Results Come Back)
-
-When a sub-agent finishes, it goes through an **announce** step:
-
-1. The sub-agent's final reply is captured
-2. A summary message is sent to the main agent's session with the result, status, and stats
-3. The main agent posts a natural-language summary to your chat
-
-Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads).
-
-### Announce Stats
-
-Each announce includes a stats line with:
-
-- Runtime duration
-- Token usage (input/output/total)
-- Estimated cost (when model pricing is configured via `models.providers.*.models[].cost`)
-- Session key, session id, and transcript path
-
-### Announce Status
-
-The announce message includes a status derived from the runtime outcome (not from model output):
-
-- **successful completion** (`ok`) — task completed normally
-- **error** — task failed (error details in notes)
-- **timeout** — task exceeded `runTimeoutSeconds`
-- **unknown** — status could not be determined
-
-
-If no user-facing announcement is needed, the main-agent summarize step can return `NO_REPLY` and nothing is posted.
-This is different from `ANNOUNCE_SKIP`, which is used in agent-to-agent announce flow (`sessions_send`).
-
-
-## Tool Policy
-
-By default, sub-agents get **all tools except** a set of denied tools that are unsafe or unnecessary for background tasks:
-
-
-
- | Denied tool | Reason |
- |-------------|--------|
- | `sessions_list` | Session management — main agent orchestrates |
- | `sessions_history` | Session management — main agent orchestrates |
- | `sessions_send` | Session management — main agent orchestrates |
- | `sessions_spawn` | No nested fan-out (sub-agents cannot spawn sub-agents) |
- | `gateway` | System admin — dangerous from sub-agent |
- | `agents_list` | System admin |
- | `whatsapp_login` | Interactive setup — not a task |
- | `session_status` | Status/scheduling — main agent coordinates |
- | `cron` | Status/scheduling — main agent coordinates |
- | `memory_search` | Pass relevant info in spawn prompt instead |
- | `memory_get` | Pass relevant info in spawn prompt instead |
-
-
-
-### Customizing Sub-Agent Tools
-
-You can further restrict sub-agent tools:
-
-```json5
-{
- tools: {
- subagents: {
- tools: {
- // deny always wins over allow
- deny: ["browser", "firecrawl"],
- },
- },
- },
-}
-```
-
-To restrict sub-agents to **only** specific tools:
-
-```json5
-{
- tools: {
- subagents: {
- tools: {
- allow: ["read", "exec", "process", "write", "edit", "apply_patch"],
- // deny still wins if set
- },
- },
- },
-}
-```
-
-
-Custom deny entries are **added to** the default deny list. If `allow` is set, only those tools are available (the default deny list still applies on top).
-
+- `/stop` in the main chat stops all depth-1 agents and cascades to their depth-2 children.
+- `/subagents kill ` stops a specific sub-agent and cascades to its children.
+- `/subagents kill all` stops all sub-agents for the requester and cascades.
## Authentication
Sub-agent auth is resolved by **agent id**, not by session type:
-- The auth store is loaded from the target agent's `agentDir`
-- The main agent's auth profiles are merged in as a **fallback** (agent profiles win on conflicts)
-- The merge is additive — main profiles are always available as fallbacks
+- The sub-agent session key is `agent::subagent:`.
+- The auth store is loaded from that agent's `agentDir`.
+- The main agent's auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
-
-Fully isolated auth per sub-agent is not currently supported.
-
+Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
-## Context and System Prompt
+## Announce
-Sub-agents receive a reduced system prompt compared to the main agent:
+Sub-agents report back via an announce step:
-- **Included:** Tooling, Workspace, Runtime sections, plus `AGENTS.md` and `TOOLS.md`
-- **Not included:** `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`
+- 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 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).
+ - `Notes:` error details and other useful context.
+- `Status` is not inferred from model output; it comes from runtime outcome signals.
-The sub-agent also receives a task-focused system prompt that instructs it to stay focused on the assigned task, complete it, and not act as the main agent.
+Announce payloads include a stats line at the end (even when wrapped):
-## Stopping Sub-Agents
+- Runtime (e.g., `runtime 5m12s`)
+- Token usage (input/output/total)
+- Estimated cost when model pricing is configured (`models.providers.*.models[].cost`)
+- `sessionKey`, `sessionId`, and transcript path (so the main agent can fetch history via `sessions_history` or inspect the file on disk)
-| Method | Effect |
-| ---------------------- | ------------------------------------------------------------------------- |
-| `/stop` in the chat | Aborts the main session **and** all active sub-agent runs spawned from it |
-| `/subagents stop ` | Stops a specific sub-agent without affecting the main session |
-| `runTimeoutSeconds` | Automatically aborts the sub-agent run after the specified time |
+## Tool Policy (sub-agent tools)
-
-`runTimeoutSeconds` does **not** auto-archive the session. The session remains until the normal archive timer fires.
-
+By default, sub-agents get **all tools except session tools** and system tools:
-## Full Configuration Example
+- `sessions_list`
+- `sessions_history`
+- `sessions_send`
+- `sessions_spawn`
+
+When `maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children.
+
+Override via config:
-
```json5
{
agents: {
defaults: {
- model: { primary: "anthropic/claude-sonnet-4" },
subagents: {
- model: "minimax/MiniMax-M2.1",
- thinking: "low",
- maxConcurrent: 4,
- archiveAfterMinutes: 30,
+ maxConcurrent: 1,
},
},
- list: [
- {
- id: "main",
- default: true,
- name: "Personal Assistant",
- },
- {
- id: "ops",
- name: "Ops Agent",
- subagents: {
- model: "anthropic/claude-sonnet-4",
- allowAgents: ["main"], // ops can spawn sub-agents under "main"
- },
- },
- ],
},
tools: {
subagents: {
tools: {
- deny: ["browser"], // sub-agents can't use the browser
+ // deny wins
+ deny: ["gateway", "cron"],
+ // if allow is set, it becomes allow-only (deny still wins)
+ // allow: ["read", "exec", "process"]
},
},
},
}
```
-
+
+## Concurrency
+
+Sub-agents use a dedicated in-process queue lane:
+
+- Lane name: `subagent`
+- Concurrency: `agents.defaults.subagents.maxConcurrent` (default `8`)
+
+## Stopping
+
+- Sending `/stop` in the requester chat aborts the requester session and stops any active sub-agent runs spawned from it, cascading to nested children.
+- `/subagents kill ` stops a specific sub-agent and cascades to its children.
## Limitations
-
-- **Best-effort announce:** If the gateway restarts, pending announce work is lost.
-- **No nested spawning:** Sub-agents cannot spawn their own sub-agents.
-- **Shared resources:** Sub-agents share the gateway process; use `maxConcurrent` as a safety valve.
-- **Auto-archive is best-effort:** Pending archive timers are lost on gateway restart.
-
-
-## See Also
-
-- [Session Tools](/concepts/session-tool) — details on `sessions_spawn` and other session tools
-- [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) — per-agent tool restrictions and sandboxing
-- [Configuration](/gateway/configuration) — `agents.defaults.subagents` reference
-- [Queue](/concepts/queue) — how the `subagent` lane works
+- Sub-agent announce is **best-effort**. If the gateway restarts, pending "announce back" work is lost.
+- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
+- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately.
+- Sub-agent context only injects `AGENTS.md` + `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`).
+- Maximum nesting depth is 5 (`maxSpawnDepth` range: 1–5). Depth 2 is recommended for most use cases.
+- `maxChildrenPerAgent` caps active children per session (default: 5, range: 1–20).
diff --git a/docs/tools/web.md b/docs/tools/web.md
index c22bc1707eb..b0e295cd22a 100644
--- a/docs/tools/web.md
+++ b/docs/tools/web.md
@@ -175,7 +175,9 @@ Search the web using your configured provider.
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
- `ui_lang` (optional): ISO language code for UI elements
-- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
+- `freshness` (optional): filter by discovery time
+ - Brave: `pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`
+ - Perplexity: `pd`, `pw`, `pm`, `py`
**Examples:**
@@ -222,6 +224,7 @@ Fetch a URL and extract readable content.
enabled: true,
maxChars: 50000,
maxCharsCap: 50000,
+ maxResponseBytes: 2000000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
maxRedirects: 3,
@@ -254,6 +257,7 @@ Notes:
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
+- `web_fetch` caps the downloaded response body size to `tools.web.fetch.maxResponseBytes` before parsing; oversized responses are truncated and include a warning.
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
- Responses are cached (default 15 minutes) to reduce repeated fetches.
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index 233a67c48b0..fad37a47a10 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -83,16 +83,25 @@ Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- 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.
+- 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.
## Chat behavior
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
+- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
- `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
- `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
+ - Gateway persists aborted partial assistant text into transcript history when buffered output exists
+ - Persisted entries include abort metadata so transcript consumers can tell abort partials from normal completion output
## Tailnet access (recommended)
diff --git a/docs/web/webchat.md b/docs/web/webchat.md
index 4dc8a985331..9853e372159 100644
--- a/docs/web/webchat.md
+++ b/docs/web/webchat.md
@@ -24,7 +24,10 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
+- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
+- Aborted runs can keep partial assistant output visible in the UI.
+- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata.
- History is always fetched from the gateway (no local file watching).
- If the gateway is unreachable, WebChat is read-only.
@@ -44,6 +47,7 @@ Channel options:
Related global options:
- `gateway.port`, `gateway.bind`: WebSocket host/port.
-- `gateway.auth.mode`, `gateway.auth.token`, `gateway.auth.password`: WebSocket auth.
+- `gateway.auth.mode`, `gateway.auth.token`, `gateway.auth.password`: WebSocket auth (token/password).
+- `gateway.auth.mode: "trusted-proxy"`: reverse-proxy auth for browser clients (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
- `gateway.remote.url`, `gateway.remote.token`, `gateway.remote.password`: remote gateway target.
- `session.*`: session storage and main key defaults.
diff --git a/docs/zh-CN/automation/hooks.md b/docs/zh-CN/automation/hooks.md
index 61f9e916e15..b5806e2bdd0 100644
--- a/docs/zh-CN/automation/hooks.md
+++ b/docs/zh-CN/automation/hooks.md
@@ -133,7 +133,7 @@ Hook 包可以附带依赖;它们将安装在 `~/.openclaw/hooks/` 下。
---
name: my-hook
description: "Short description of what this hook does"
-homepage: https://docs.openclaw.ai/hooks#my-hook
+homepage: https://docs.openclaw.ai/automation/hooks#my-hook
metadata:
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
---
diff --git a/docs/zh-CN/channels/telegram.md b/docs/zh-CN/channels/telegram.md
index 90a21149e37..27540da984e 100644
--- a/docs/zh-CN/channels/telegram.md
+++ b/docs/zh-CN/channels/telegram.md
@@ -724,7 +724,7 @@ Telegram 反应作为**单独的 `message_reaction` 事件**到达,而不是
- `channels.telegram.groups..topics..requireMention`:每话题提及门控覆盖。
- `channels.telegram.capabilities.inlineButtons`:`off | dm | group | all | allowlist`(默认:allowlist)。
- `channels.telegram.accounts..capabilities.inlineButtons`:每账户覆盖。
-- `channels.telegram.replyToMode`:`off | first | all`(默认:`first`)。
+- `channels.telegram.replyToMode`:`off | first | all`(默认:`off`)。
- `channels.telegram.textChunkLimit`:出站分块大小(字符)。
- `channels.telegram.chunkMode`:`length`(默认)或 `newline` 在长度分块之前按空行(段落边界)分割。
- `channels.telegram.linkPreview`:切换出站消息的链接预览(默认:true)。
diff --git a/docs/zh-CN/cli/hooks.md b/docs/zh-CN/cli/hooks.md
index 015cd02bb3c..231099ffaf7 100644
--- a/docs/zh-CN/cli/hooks.md
+++ b/docs/zh-CN/cli/hooks.md
@@ -96,7 +96,7 @@ Details:
Source: openclaw-bundled
Path: /path/to/openclaw/hooks/bundled/session-memory/HOOK.md
Handler: /path/to/openclaw/hooks/bundled/session-memory/handler.ts
- Homepage: https://docs.openclaw.ai/hooks#session-memory
+ Homepage: https://docs.openclaw.ai/automation/hooks#session-memory
Events: command:new
Requirements:
diff --git a/docs/zh-CN/concepts/system-prompt.md b/docs/zh-CN/concepts/system-prompt.md
index cc9512125a5..f40be64c12b 100644
--- a/docs/zh-CN/concepts/system-prompt.md
+++ b/docs/zh-CN/concepts/system-prompt.md
@@ -15,7 +15,7 @@ x-i18n:
# 系统提示词
-OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 p-coding-agent 默认提示词。
+OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 pi-coding-agent 默认提示词。
该提示词由 OpenClaw 组装并注入到每次智能体运行中。
diff --git a/docs/zh-CN/help/submitting-a-pr.md b/docs/zh-CN/help/submitting-a-pr.md
deleted file mode 100644
index b2feee4dc04..00000000000
--- a/docs/zh-CN/help/submitting-a-pr.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-summary: 如何提交高信号 PR
-title: 提交 PR
----
-
-# 提交 PR
-
-该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting a PR](/help/submitting-a-pr)。
diff --git a/docs/zh-CN/help/submitting-an-issue.md b/docs/zh-CN/help/submitting-an-issue.md
deleted file mode 100644
index c328002a71b..00000000000
--- a/docs/zh-CN/help/submitting-an-issue.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-summary: 如何提交高信号 Issue
-title: 提交 Issue
----
-
-# 提交 Issue
-
-该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting an Issue](/help/submitting-an-issue)。
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index 1cbe3376b53..b040a6fb29c 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
- "version": "2026.2.13",
+ "version": "2026.2.16",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts
new file mode 100644
index 00000000000..0ec539644fe
--- /dev/null
+++ b/extensions/bluebubbles/src/account-resolve.ts
@@ -0,0 +1,29 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import { resolveBlueBubblesAccount } from "./accounts.js";
+
+export type BlueBubblesAccountResolveOpts = {
+ serverUrl?: string;
+ password?: string;
+ accountId?: string;
+ cfg?: OpenClawConfig;
+};
+
+export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
+ baseUrl: string;
+ password: string;
+ accountId: string;
+} {
+ const account = resolveBlueBubblesAccount({
+ cfg: params.cfg ?? {},
+ accountId: params.accountId,
+ });
+ const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
+ const password = params.password?.trim() || account.config.password?.trim();
+ if (!baseUrl) {
+ throw new Error("BlueBubbles serverUrl is required");
+ }
+ if (!password) {
+ throw new Error("BlueBubbles password is required");
+ }
+ return { baseUrl, password, accountId: account.accountId };
+}
diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts
index 04320701e5f..284dd2add69 100644
--- a/extensions/bluebubbles/src/accounts.ts
+++ b/extensions/bluebubbles/src/accounts.ts
@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
-import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
+import { createAccountListHelpers } from "openclaw/plugin-sdk";
+import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
@@ -11,29 +12,9 @@ export type ResolvedBlueBubblesAccount = {
baseUrl?: string;
};
-function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
- const accounts = cfg.channels?.bluebubbles?.accounts;
- if (!accounts || typeof accounts !== "object") {
- return [];
- }
- return Object.keys(accounts).filter(Boolean);
-}
-
-export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
- const ids = listConfiguredAccountIds(cfg);
- if (ids.length === 0) {
- return [DEFAULT_ACCOUNT_ID];
- }
- return ids.toSorted((a, b) => a.localeCompare(b));
-}
-
-export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
- const ids = listBlueBubblesAccountIds(cfg);
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
- return DEFAULT_ACCOUNT_ID;
- }
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
-}
+const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("bluebubbles");
+export const listBlueBubblesAccountIds = listAccountIds;
+export const resolveDefaultBlueBubblesAccountId = resolveDefaultAccountId;
function resolveAccountConfig(
cfg: OpenClawConfig,
diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts
index 8dc55b1eff3..efb4859fac4 100644
--- a/extensions/bluebubbles/src/actions.test.ts
+++ b/extensions/bluebubbles/src/actions.test.ts
@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
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 }) => {
@@ -41,9 +42,22 @@ vi.mock("./monitor.js", () => ({
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
}));
+vi.mock("./probe.js", () => ({
+ isMacOS26OrHigher: vi.fn().mockReturnValue(false),
+ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
+}));
+
describe("bluebubblesMessageActions", () => {
+ const listActions = bluebubblesMessageActions.listActions!;
+ const supportsAction = bluebubblesMessageActions.supportsAction!;
+ const extractToolSend = bluebubblesMessageActions.extractToolSend!;
+ const handleAction = bluebubblesMessageActions.handleAction!;
+ const callHandleAction = (ctx: Omit[0], "channel">) =>
+ handleAction({ channel: "bluebubbles", ...ctx });
+
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
describe("listActions", () => {
@@ -51,7 +65,7 @@ describe("bluebubblesMessageActions", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: false } },
};
- const actions = bluebubblesMessageActions.listActions({ cfg });
+ const actions = listActions({ cfg });
expect(actions).toEqual([]);
});
@@ -59,7 +73,7 @@ describe("bluebubblesMessageActions", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: true } },
};
- const actions = bluebubblesMessageActions.listActions({ cfg });
+ const actions = listActions({ cfg });
expect(actions).toEqual([]);
});
@@ -73,7 +87,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- const actions = bluebubblesMessageActions.listActions({ cfg });
+ const actions = listActions({ cfg });
expect(actions).toContain("react");
});
@@ -88,41 +102,66 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- const actions = bluebubblesMessageActions.listActions({ cfg });
+ const actions = listActions({ cfg });
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
expect(actions).toContain("unsend");
});
+
+ it("hides private-api actions when private API is disabled", () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+ const cfg: OpenClawConfig = {
+ channels: {
+ bluebubbles: {
+ enabled: true,
+ serverUrl: "http://localhost:1234",
+ password: "test-password",
+ },
+ },
+ };
+ const actions = listActions({ cfg });
+ expect(actions).toContain("sendAttachment");
+ expect(actions).not.toContain("react");
+ expect(actions).not.toContain("reply");
+ expect(actions).not.toContain("sendWithEffect");
+ expect(actions).not.toContain("edit");
+ expect(actions).not.toContain("unsend");
+ expect(actions).not.toContain("renameGroup");
+ expect(actions).not.toContain("setGroupIcon");
+ expect(actions).not.toContain("addParticipant");
+ expect(actions).not.toContain("removeParticipant");
+ expect(actions).not.toContain("leaveGroup");
+ });
});
describe("supportsAction", () => {
it("returns true for react action", () => {
- expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
+ expect(supportsAction({ action: "react" })).toBe(true);
});
it("returns true for all supported actions", () => {
- expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
- expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
+ expect(supportsAction({ action: "edit" })).toBe(true);
+ expect(supportsAction({ action: "unsend" })).toBe(true);
+ expect(supportsAction({ action: "reply" })).toBe(true);
+ expect(supportsAction({ action: "sendWithEffect" })).toBe(true);
+ expect(supportsAction({ action: "renameGroup" })).toBe(true);
+ expect(supportsAction({ action: "setGroupIcon" })).toBe(true);
+ expect(supportsAction({ action: "addParticipant" })).toBe(true);
+ expect(supportsAction({ action: "removeParticipant" })).toBe(true);
+ expect(supportsAction({ action: "leaveGroup" })).toBe(true);
+ expect(supportsAction({ action: "sendAttachment" })).toBe(true);
});
it("returns false for unsupported actions", () => {
- expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
- expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
+ expect(supportsAction({ action: "delete" as never })).toBe(false);
+ expect(supportsAction({ action: "unknown" as never })).toBe(false);
});
});
describe("extractToolSend", () => {
it("extracts send params from sendMessage action", () => {
- const result = bluebubblesMessageActions.extractToolSend({
+ const result = extractToolSend({
args: {
action: "sendMessage",
to: "+15551234567",
@@ -136,14 +175,14 @@ describe("bluebubblesMessageActions", () => {
});
it("returns null for non-sendMessage action", () => {
- const result = bluebubblesMessageActions.extractToolSend({
+ const result = extractToolSend({
args: { action: "react", to: "+15551234567" },
});
expect(result).toBeNull();
});
it("returns null when to is missing", () => {
- const result = bluebubblesMessageActions.extractToolSend({
+ const result = extractToolSend({
args: { action: "sendMessage" },
});
expect(result).toBeNull();
@@ -161,8 +200,8 @@ describe("bluebubblesMessageActions", () => {
},
};
await expect(
- bluebubblesMessageActions.handleAction({
- action: "unknownAction",
+ callHandleAction({
+ action: "unknownAction" as never,
params: {},
cfg,
accountId: null,
@@ -180,7 +219,7 @@ describe("bluebubblesMessageActions", () => {
},
};
await expect(
- bluebubblesMessageActions.handleAction({
+ callHandleAction({
action: "react",
params: { messageId: "msg-123" },
cfg,
@@ -189,6 +228,26 @@ describe("bluebubblesMessageActions", () => {
).rejects.toThrow(/emoji/i);
});
+ it("throws a private-api error for private-only actions when disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+ const cfg: OpenClawConfig = {
+ channels: {
+ bluebubbles: {
+ serverUrl: "http://localhost:1234",
+ password: "test-password",
+ },
+ },
+ };
+ await expect(
+ callHandleAction({
+ action: "react",
+ params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
+ cfg,
+ accountId: null,
+ }),
+ ).rejects.toThrow("requires Private API");
+ });
+
it("throws when messageId is missing", async () => {
const cfg: OpenClawConfig = {
channels: {
@@ -199,7 +258,7 @@ describe("bluebubblesMessageActions", () => {
},
};
await expect(
- bluebubblesMessageActions.handleAction({
+ callHandleAction({
action: "react",
params: { emoji: "❤️" },
cfg,
@@ -221,7 +280,7 @@ describe("bluebubblesMessageActions", () => {
},
};
await expect(
- bluebubblesMessageActions.handleAction({
+ callHandleAction({
action: "react",
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
cfg,
@@ -241,7 +300,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- const result = await bluebubblesMessageActions.handleAction({
+ const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
@@ -276,7 +335,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- const result = await bluebubblesMessageActions.handleAction({
+ const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
@@ -312,7 +371,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "react",
params: {
emoji: "👍",
@@ -342,7 +401,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "react",
params: {
emoji: "😂",
@@ -374,7 +433,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "react",
params: {
emoji: "👍",
@@ -413,7 +472,7 @@ describe("bluebubblesMessageActions", () => {
},
};
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "react",
params: {
emoji: "❤️",
@@ -448,7 +507,7 @@ describe("bluebubblesMessageActions", () => {
};
await expect(
- bluebubblesMessageActions.handleAction({
+ callHandleAction({
action: "react",
params: {
emoji: "❤️",
@@ -473,7 +532,7 @@ describe("bluebubblesMessageActions", () => {
},
};
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "edit",
params: { messageId: "msg-123", message: "updated" },
cfg,
@@ -499,7 +558,7 @@ describe("bluebubblesMessageActions", () => {
},
};
- const result = await bluebubblesMessageActions.handleAction({
+ const result = await callHandleAction({
action: "sendWithEffect",
params: {
message: "peekaboo",
@@ -534,7 +593,7 @@ describe("bluebubblesMessageActions", () => {
const base64Buffer = Buffer.from("voice").toString("base64");
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "sendAttachment",
params: {
to: "+15551234567",
@@ -567,7 +626,7 @@ describe("bluebubblesMessageActions", () => {
};
await expect(
- bluebubblesMessageActions.handleAction({
+ callHandleAction({
action: "setGroupIcon",
params: { chatGuid: "iMessage;-;chat-guid" },
cfg,
@@ -592,7 +651,7 @@ describe("bluebubblesMessageActions", () => {
const testBuffer = Buffer.from("fake-image-data");
const base64Buffer = testBuffer.toString("base64");
- const result = await bluebubblesMessageActions.handleAction({
+ const result = await callHandleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
@@ -629,7 +688,7 @@ describe("bluebubblesMessageActions", () => {
const base64Buffer = Buffer.from("test").toString("base64");
- await bluebubblesMessageActions.handleAction({
+ await callHandleAction({
action: "setGroupIcon",
params: {
chatGuid: "iMessage;-;chat-guid",
diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts
index a3074d4e545..22c5d3e42e8 100644
--- a/extensions/bluebubbles/src/actions.ts
+++ b/extensions/bluebubbles/src/actions.ts
@@ -10,7 +10,6 @@ import {
type ChannelMessageActionName,
type ChannelToolSend,
} from "openclaw/plugin-sdk";
-import type { BlueBubblesSendTarget } from "./types.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import {
@@ -23,10 +22,11 @@ import {
leaveBlueBubblesChat,
} from "./chat.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
-import { isMacOS26OrHigher } from "./probe.js";
+import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
+import type { BlueBubblesSendTarget } from "./types.js";
const providerId = "bluebubbles";
@@ -71,6 +71,18 @@ function readBooleanParam(params: Record, key: string): boolean
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES);
+const PRIVATE_API_ACTIONS = new Set([
+ "react",
+ "edit",
+ "unsend",
+ "reply",
+ "sendWithEffect",
+ "renameGroup",
+ "setGroupIcon",
+ "addParticipant",
+ "removeParticipant",
+ "leaveGroup",
+]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
@@ -81,11 +93,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set();
const macOS26 = isMacOS26OrHigher(account.accountId);
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) {
continue;
}
+ if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
+ continue;
+ }
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
continue;
}
@@ -116,6 +132,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
const opts = { cfg: cfg, accountId: accountId ?? undefined };
+ const assertPrivateApiEnabled = () => {
+ if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
+ throw new Error(
+ `BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
+ );
+ }
+ };
// Helper to resolve chatGuid from various params or session context
const resolveChatGuid = async (): Promise => {
@@ -159,6 +182,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle react action
if (action === "react") {
+ assertPrivateApiEnabled();
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
@@ -193,6 +217,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle edit action
if (action === "edit") {
+ assertPrivateApiEnabled();
// Edit is not supported on macOS 26+
if (isMacOS26OrHigher(accountId ?? undefined)) {
throw new Error(
@@ -234,6 +259,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle unsend action
if (action === "unsend") {
+ assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId");
if (!rawMessageId) {
throw new Error(
@@ -255,6 +281,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle reply action
if (action === "reply") {
+ assertPrivateApiEnabled();
const rawMessageId = readStringParam(params, "messageId");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
@@ -289,6 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle sendWithEffect action
if (action === "sendWithEffect") {
+ assertPrivateApiEnabled();
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
@@ -321,6 +349,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle renameGroup action
if (action === "renameGroup") {
+ assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
if (!displayName) {
@@ -334,6 +363,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle setGroupIcon action
if (action === "setGroupIcon") {
+ assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const base64Buffer = readStringParam(params, "buffer");
const filename =
@@ -361,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle addParticipant action
if (action === "addParticipant") {
+ assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
@@ -374,6 +405,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle removeParticipant action
if (action === "removeParticipant") {
+ assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
if (!address) {
@@ -387,6 +419,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle leaveGroup action
if (action === "leaveGroup") {
+ assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid();
await leaveBlueBubblesChat(resolvedChatGuid, opts);
diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts
index 9bc0e4d217b..78d529106e8 100644
--- a/extensions/bluebubbles/src/attachments.test.ts
+++ b/extensions/bluebubbles/src/attachments.test.ts
@@ -1,31 +1,18 @@
-import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
-import type { BlueBubblesAttachment } from "./types.js";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.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,
- };
- }),
-}));
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
+import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
+installBlueBubblesFetchTestHooks({
+ mockFetch,
+ privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
+});
+
describe("downloadBlueBubblesAttachment", () => {
- beforeEach(() => {
- vi.stubGlobal("fetch", mockFetch);
- mockFetch.mockReset();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
@@ -242,6 +229,8 @@ describe("sendBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
@@ -342,4 +331,27 @@ describe("sendBlueBubblesAttachment", () => {
expect(bodyText).toContain('filename="evil.mp3"');
expect(bodyText).toContain('name="evil.mp3"');
});
+
+ it("downgrades attachment reply threading when private API is disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
+ });
+
+ await sendBlueBubblesAttachment({
+ to: "chat_guid:iMessage;-;+15551234567",
+ buffer: new Uint8Array([1, 2, 3]),
+ filename: "photo.jpg",
+ contentType: "image/jpeg",
+ replyToMessageGuid: "reply-guid-123",
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
+ });
+
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
+ const bodyText = decodeBody(body);
+ expect(bodyText).not.toContain('name="method"');
+ 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 1d18126e9ad..e60022fca24 100644
--- a/extensions/bluebubbles/src/attachments.ts
+++ b/extensions/bluebubbles/src/attachments.ts
@@ -1,9 +1,11 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
-import { resolveBlueBubblesAccount } from "./accounts.js";
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
+import { postMultipartFormData } from "./multipart.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
-import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
@@ -52,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
}
function resolveAccount(params: BlueBubblesAttachmentOpts) {
- const account = resolveBlueBubblesAccount({
- cfg: params.cfg ?? {},
- accountId: params.accountId,
- });
- const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
- const password = params.password?.trim() || account.config.password?.trim();
- if (!baseUrl) {
- throw new Error("BlueBubbles serverUrl is required");
- }
- if (!password) {
- throw new Error("BlueBubbles password is required");
- }
- return { baseUrl, password };
+ return resolveBlueBubblesServerAccount(params);
}
export async function downloadBlueBubblesAttachment(
@@ -101,52 +91,6 @@ export type SendBlueBubblesAttachmentResult = {
messageId: string;
};
-function resolveSendTarget(raw: string): BlueBubblesSendTarget {
- const parsed = parseBlueBubblesTarget(raw);
- if (parsed.kind === "handle") {
- return {
- kind: "handle",
- address: normalizeBlueBubblesHandle(parsed.to),
- service: parsed.service,
- };
- }
- if (parsed.kind === "chat_id") {
- return { kind: "chat_id", chatId: parsed.chatId };
- }
- if (parsed.kind === "chat_guid") {
- return { kind: "chat_guid", chatGuid: parsed.chatGuid };
- }
- return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
-}
-
-function extractMessageId(payload: unknown): string {
- if (!payload || typeof payload !== "object") {
- return "unknown";
- }
- const record = payload as Record;
- const data =
- record.data && typeof record.data === "object"
- ? (record.data as Record)
- : null;
- const candidates = [
- record.messageId,
- record.guid,
- record.id,
- data?.messageId,
- data?.guid,
- data?.id,
- ];
- for (const candidate of candidates) {
- if (typeof candidate === "string" && candidate.trim()) {
- return candidate.trim();
- }
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
- return String(candidate);
- }
- }
- return "unknown";
-}
-
/**
* Send an attachment via BlueBubbles API.
* Supports sending media files (images, videos, audio, documents) to a chat.
@@ -169,7 +113,8 @@ export async function sendBlueBubblesAttachment(params: {
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
filename = sanitizeFilename(filename, fallbackName);
contentType = contentType?.trim() || undefined;
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
const isAudioMessage = wantsVoice;
@@ -191,7 +136,7 @@ export async function sendBlueBubblesAttachment(params: {
}
}
- const target = resolveSendTarget(to);
+ const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
@@ -238,7 +183,9 @@ export async function sendBlueBubblesAttachment(params: {
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
- addField("method", "private-api");
+ if (privateApiStatus !== false) {
+ addField("method", "private-api");
+ }
// Add isAudioMessage flag for voice memos
if (isAudioMessage) {
@@ -246,7 +193,7 @@ export async function sendBlueBubblesAttachment(params: {
}
const trimmedReplyTo = replyToMessageGuid?.trim();
- if (trimmedReplyTo) {
+ if (trimmedReplyTo && privateApiStatus !== false) {
addField("selectedMessageGuid", trimmedReplyTo);
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
}
@@ -261,26 +208,12 @@ export async function sendBlueBubblesAttachment(params: {
// Close the multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
- // Combine all parts into a single buffer
- const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
- const body = new Uint8Array(totalLength);
- let offset = 0;
- for (const part of parts) {
- body.set(part, offset);
- offset += part.length;
- }
-
- const res = await blueBubblesFetchWithTimeout(
+ const res = await postMultipartFormData({
url,
- {
- method: "POST",
- headers: {
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
- },
- body,
- },
- opts.timeoutMs ?? 60_000, // longer timeout for file uploads
- );
+ boundary,
+ parts,
+ timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
+ });
if (!res.ok) {
const errorText = await res.text();
@@ -295,7 +228,7 @@ export async function sendBlueBubblesAttachment(params: {
}
try {
const parsed = JSON.parse(responseBody) as unknown;
- return { messageId: extractMessageId(parsed) };
+ return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts
index 39ac3ba325a..b5dd0973449 100644
--- a/extensions/bluebubbles/src/chat.test.ts
+++ b/extensions/bluebubbles/src/chat.test.ts
@@ -1,30 +1,17 @@
-import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import "./test-mocks.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.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,
- };
- }),
-}));
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
+installBlueBubblesFetchTestHooks({
+ mockFetch,
+ privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
+});
+
describe("chat", () => {
- beforeEach(() => {
- vi.stubGlobal("fetch", mockFetch);
- mockFetch.mockReset();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {
@@ -73,6 +60,17 @@ describe("chat", () => {
);
});
+ it("does not send read receipt when private API is disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+
+ await markBlueBubblesChatRead("iMessage;-;+15551234567", {
+ serverUrl: "http://localhost:1234",
+ password: "test-password",
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -190,6 +188,17 @@ describe("chat", () => {
);
});
+ it("does not send typing when private API is disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+
+ await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
+ serverUrl: "http://localhost:1234",
+ password: "test",
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
it("sends typing stop with DELETE method", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -348,6 +357,17 @@ describe("chat", () => {
).rejects.toThrow("password is required");
});
+ it("throws when private API is disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+ await expect(
+ setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
+ serverUrl: "http://localhost:1234",
+ password: "test",
+ }),
+ ).rejects.toThrow("requires Private API");
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
it("sets group icon successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts
index 115dc06aae7..354e7076722 100644
--- a/extensions/bluebubbles/src/chat.ts
+++ b/extensions/bluebubbles/src/chat.ts
@@ -1,7 +1,9 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
-import { resolveBlueBubblesAccount } from "./accounts.js";
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
+import { postMultipartFormData } from "./multipart.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {
@@ -13,19 +15,15 @@ export type BlueBubblesChatOpts = {
};
function resolveAccount(params: BlueBubblesChatOpts) {
- const account = resolveBlueBubblesAccount({
- cfg: params.cfg ?? {},
- accountId: params.accountId,
- });
- const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
- const password = params.password?.trim() || account.config.password?.trim();
- if (!baseUrl) {
- throw new Error("BlueBubbles serverUrl is required");
+ return resolveBlueBubblesServerAccount(params);
+}
+
+function assertPrivateApiEnabled(accountId: string, feature: string): void {
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
+ throw new Error(
+ `BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
+ );
}
- if (!password) {
- throw new Error("BlueBubbles password is required");
- }
- return { baseUrl, password };
}
export async function markBlueBubblesChatRead(
@@ -36,7 +34,10 @@ export async function markBlueBubblesChatRead(
if (!trimmed) {
return;
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
+ return;
+ }
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
@@ -58,7 +59,10 @@ export async function sendBlueBubblesTyping(
if (!trimmed) {
return;
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
+ return;
+ }
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
@@ -93,7 +97,8 @@ export async function editBlueBubblesMessage(
throw new Error("BlueBubbles edit requires newText");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "edit");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
@@ -135,7 +140,8 @@ export async function unsendBlueBubblesMessage(
throw new Error("BlueBubbles unsend requires messageGuid");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "unsend");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
@@ -175,7 +181,8 @@ export async function renameBlueBubblesChat(
throw new Error("BlueBubbles rename requires chatGuid");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "renameGroup");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
@@ -215,7 +222,8 @@ export async function addBlueBubblesParticipant(
throw new Error("BlueBubbles addParticipant requires address");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "addParticipant");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
@@ -255,7 +263,8 @@ export async function removeBlueBubblesParticipant(
throw new Error("BlueBubbles removeParticipant requires address");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "removeParticipant");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
@@ -292,7 +301,8 @@ export async function leaveBlueBubblesChat(
throw new Error("BlueBubbles leaveChat requires chatGuid");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "leaveGroup");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
@@ -325,7 +335,8 @@ export async function setGroupIconBlueBubbles(
throw new Error("BlueBubbles setGroupIcon requires image buffer");
}
- const { baseUrl, password } = resolveAccount(opts);
+ const { baseUrl, password, accountId } = resolveAccount(opts);
+ assertPrivateApiEnabled(accountId, "setGroupIcon");
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
@@ -354,26 +365,12 @@ export async function setGroupIconBlueBubbles(
// Close multipart body
parts.push(encoder.encode(`--${boundary}--\r\n`));
- // Combine into single buffer
- const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
- const body = new Uint8Array(totalLength);
- let offset = 0;
- for (const part of parts) {
- body.set(part, offset);
- offset += part.length;
- }
-
- const res = await blueBubblesFetchWithTimeout(
+ const res = await postMultipartFormData({
url,
- {
- method: "POST",
- headers: {
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
- },
- body,
- },
- opts.timeoutMs ?? 60_000, // longer timeout for file uploads
- );
+ boundary,
+ parts,
+ timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
+ });
if (!res.ok) {
const errorText = await res.text().catch(() => "");
diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts
index 3a5e1b393b7..097071757c3 100644
--- a/extensions/bluebubbles/src/config-schema.ts
+++ b/extensions/bluebubbles/src/config-schema.ts
@@ -40,6 +40,7 @@ const bluebubblesAccountSchema = z.object({
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
+ mediaLocalRoots: z.array(z.string()).optional(),
sendReadReceipts: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts
new file mode 100644
index 00000000000..901c90f2d4f
--- /dev/null
+++ b/extensions/bluebubbles/src/media-send.test.ts
@@ -0,0 +1,256 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { sendBlueBubblesMedia } from "./media-send.js";
+import { setBlueBubblesRuntime } from "./runtime.js";
+
+const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn());
+const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
+const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn((id: string) => id));
+
+vi.mock("./attachments.js", () => ({
+ sendBlueBubblesAttachment: sendBlueBubblesAttachmentMock,
+}));
+
+vi.mock("./send.js", () => ({
+ sendMessageBlueBubbles: sendMessageBlueBubblesMock,
+}));
+
+vi.mock("./monitor.js", () => ({
+ resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
+}));
+
+type RuntimeMocks = {
+ detectMime: ReturnType;
+ fetchRemoteMedia: ReturnType;
+};
+
+let runtimeMocks: RuntimeMocks;
+const tempDirs: string[] = [];
+
+function createMockRuntime(): { runtime: PluginRuntime; mocks: RuntimeMocks } {
+ const detectMime = vi.fn().mockResolvedValue("text/plain");
+ const fetchRemoteMedia = vi.fn().mockResolvedValue({
+ buffer: new Uint8Array([1, 2, 3]),
+ contentType: "image/png",
+ fileName: "remote.png",
+ });
+ return {
+ runtime: {
+ version: "1.0.0",
+ media: {
+ detectMime,
+ },
+ channel: {
+ media: {
+ fetchRemoteMedia,
+ },
+ },
+ } as unknown as PluginRuntime,
+ mocks: { detectMime, fetchRemoteMedia },
+ };
+}
+
+function createConfig(overrides?: Record): OpenClawConfig {
+ return {
+ channels: {
+ bluebubbles: {
+ ...overrides,
+ },
+ },
+ } as unknown as OpenClawConfig;
+}
+
+async function makeTempDir(): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-bb-media-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+beforeEach(() => {
+ const runtime = createMockRuntime();
+ runtimeMocks = runtime.mocks;
+ setBlueBubblesRuntime(runtime.runtime);
+ sendBlueBubblesAttachmentMock.mockReset();
+ sendBlueBubblesAttachmentMock.mockResolvedValue({ messageId: "msg-1" });
+ sendMessageBlueBubblesMock.mockReset();
+ sendMessageBlueBubblesMock.mockResolvedValue({ messageId: "msg-caption" });
+ resolveBlueBubblesMessageIdMock.mockClear();
+});
+
+afterEach(async () => {
+ while (tempDirs.length > 0) {
+ const dir = tempDirs.pop();
+ if (!dir) {
+ continue;
+ }
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+describe("sendBlueBubblesMedia local-path hardening", () => {
+ it("rejects local paths when mediaLocalRoots is not configured", async () => {
+ await expect(
+ sendBlueBubblesMedia({
+ cfg: createConfig(),
+ to: "chat:123",
+ mediaPath: "/etc/passwd",
+ }),
+ ).rejects.toThrow(/mediaLocalRoots/i);
+
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects local paths outside configured mediaLocalRoots", async () => {
+ const allowedRoot = await makeTempDir();
+ const outsideDir = await makeTempDir();
+ const outsideFile = path.join(outsideDir, "outside.txt");
+ await fs.writeFile(outsideFile, "not allowed", "utf8");
+
+ await expect(
+ sendBlueBubblesMedia({
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
+ to: "chat:123",
+ mediaPath: outsideFile,
+ }),
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
+
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
+ });
+
+ it("allows local paths that are explicitly configured", async () => {
+ const allowedRoot = await makeTempDir();
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
+ await fs.writeFile(allowedFile, "allowed", "utf8");
+
+ const result = await sendBlueBubblesMedia({
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
+ to: "chat:123",
+ mediaPath: allowedFile,
+ });
+
+ expect(result).toEqual({ messageId: "msg-1" });
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({
+ filename: "allowed.txt",
+ contentType: "text/plain",
+ }),
+ );
+ expect(runtimeMocks.detectMime).toHaveBeenCalled();
+ });
+
+ it("allows file:// media paths and file:// local roots", async () => {
+ const allowedRoot = await makeTempDir();
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
+ await fs.writeFile(allowedFile, "allowed", "utf8");
+
+ const result = await sendBlueBubblesMedia({
+ cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }),
+ to: "chat:123",
+ mediaPath: pathToFileURL(allowedFile).toString(),
+ });
+
+ expect(result).toEqual({ messageId: "msg-1" });
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
+ expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({
+ filename: "allowed.txt",
+ }),
+ );
+ });
+
+ it("uses account-specific mediaLocalRoots over top-level roots", async () => {
+ const baseRoot = await makeTempDir();
+ const accountRoot = await makeTempDir();
+ const baseFile = path.join(baseRoot, "base.txt");
+ const accountFile = path.join(accountRoot, "account.txt");
+ await fs.writeFile(baseFile, "base", "utf8");
+ await fs.writeFile(accountFile, "account", "utf8");
+
+ const cfg = createConfig({
+ mediaLocalRoots: [baseRoot],
+ accounts: {
+ work: {
+ mediaLocalRoots: [accountRoot],
+ },
+ },
+ });
+
+ await expect(
+ sendBlueBubblesMedia({
+ cfg,
+ to: "chat:123",
+ accountId: "work",
+ mediaPath: baseFile,
+ }),
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
+
+ const result = await sendBlueBubblesMedia({
+ cfg,
+ to: "chat:123",
+ accountId: "work",
+ mediaPath: accountFile,
+ });
+
+ expect(result).toEqual({ messageId: "msg-1" });
+ });
+
+ it("rejects symlink escapes under an allowed root", async () => {
+ const allowedRoot = await makeTempDir();
+ const outsideDir = await makeTempDir();
+ const outsideFile = path.join(outsideDir, "secret.txt");
+ const linkPath = path.join(allowedRoot, "link.txt");
+ await fs.writeFile(outsideFile, "secret", "utf8");
+
+ try {
+ await fs.symlink(outsideFile, linkPath);
+ } catch {
+ // Some environments disallow symlink creation; skip without failing the suite.
+ return;
+ }
+
+ await expect(
+ sendBlueBubblesMedia({
+ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }),
+ to: "chat:123",
+ mediaPath: linkPath,
+ }),
+ ).rejects.toThrow(/not under any configured mediaLocalRoots/i);
+
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects relative mediaLocalRoots entries", async () => {
+ const allowedRoot = await makeTempDir();
+ const allowedFile = path.join(allowedRoot, "allowed.txt");
+ const relativeRoot = path.relative(process.cwd(), allowedRoot);
+ await fs.writeFile(allowedFile, "allowed", "utf8");
+
+ await expect(
+ sendBlueBubblesMedia({
+ cfg: createConfig({ mediaLocalRoots: [relativeRoot] }),
+ to: "chat:123",
+ mediaPath: allowedFile,
+ }),
+ ).rejects.toThrow(/must be absolute paths/i);
+
+ expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps remote URL flow unchanged", async () => {
+ await sendBlueBubblesMedia({
+ cfg: createConfig(),
+ to: "chat:123",
+ mediaUrl: "https://example.com/file.png",
+ });
+
+ expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith(
+ expect.objectContaining({ url: "https://example.com/file.png" }),
+ );
+ expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts
index ab757210567..797b2b92fae 100644
--- a/extensions/bluebubbles/src/media-send.ts
+++ b/extensions/bluebubbles/src/media-send.ts
@@ -1,6 +1,10 @@
+import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
+import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js";
@@ -32,6 +36,141 @@ function resolveLocalMediaPath(source: string): string {
}
}
+function expandHomePath(input: string): string {
+ if (input === "~") {
+ return os.homedir();
+ }
+ if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
+ return path.join(os.homedir(), input.slice(2));
+ }
+ return input;
+}
+
+function resolveConfiguredPath(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ throw new Error("Empty mediaLocalRoots entry is not allowed");
+ }
+ if (trimmed.startsWith("file://")) {
+ let parsed: string;
+ try {
+ parsed = fileURLToPath(trimmed);
+ } catch {
+ throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
+ }
+ if (!path.isAbsolute(parsed)) {
+ throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
+ }
+ return parsed;
+ }
+ const resolved = expandHomePath(trimmed);
+ if (!path.isAbsolute(resolved)) {
+ throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
+ }
+ return resolved;
+}
+
+function isPathInsideRoot(candidate: string, root: string): boolean {
+ const normalizedCandidate = path.normalize(candidate);
+ const normalizedRoot = path.normalize(root);
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ if (process.platform === "win32") {
+ const candidateLower = normalizedCandidate.toLowerCase();
+ const rootLower = normalizedRoot.toLowerCase();
+ const rootWithSepLower = rootWithSep.toLowerCase();
+ return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
+ }
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
+}
+
+function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
+ const account = resolveBlueBubblesAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ return (account.config.mediaLocalRoots ?? [])
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
+async function assertLocalMediaPathAllowed(params: {
+ localPath: string;
+ localRoots: string[];
+ accountId?: string;
+}): Promise<{ data: Buffer; realPath: string; sizeBytes: number }> {
+ if (params.localRoots.length === 0) {
+ throw new Error(
+ `Local BlueBubbles media paths are disabled by default. Set channels.bluebubbles.mediaLocalRoots${
+ params.accountId
+ ? ` or channels.bluebubbles.accounts.${params.accountId}.mediaLocalRoots`
+ : ""
+ } to explicitly allow local file directories.`,
+ );
+ }
+
+ const resolvedLocalPath = path.resolve(params.localPath);
+ const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
+ const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
+
+ for (const rootEntry of params.localRoots) {
+ const resolvedRootInput = resolveConfiguredPath(rootEntry);
+ const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
+ if (
+ relativeToRoot.startsWith("..") ||
+ path.isAbsolute(relativeToRoot) ||
+ relativeToRoot === ""
+ ) {
+ continue;
+ }
+
+ let rootReal: string;
+ try {
+ rootReal = await fs.realpath(resolvedRootInput);
+ } catch {
+ rootReal = path.resolve(resolvedRootInput);
+ }
+ const candidatePath = path.resolve(rootReal, relativeToRoot);
+
+ if (!isPathInsideRoot(candidatePath, rootReal)) {
+ continue;
+ }
+
+ let handle: Awaited> | null = null;
+ try {
+ handle = await fs.open(candidatePath, openFlags);
+ const realPath = await fs.realpath(candidatePath);
+ if (!isPathInsideRoot(realPath, rootReal)) {
+ continue;
+ }
+
+ const stat = await handle.stat();
+ if (!stat.isFile()) {
+ continue;
+ }
+ const realStat = await fs.stat(realPath);
+ if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
+ continue;
+ }
+
+ const data = await handle.readFile();
+ return { data, realPath, sizeBytes: stat.size };
+ } catch {
+ // Try next configured root.
+ continue;
+ } finally {
+ if (handle) {
+ await handle.close().catch(() => {});
+ }
+ }
+ }
+
+ throw new Error(
+ `Local media path is not under any configured mediaLocalRoots entry: ${params.localPath}`,
+ );
+}
+
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) {
return undefined;
@@ -88,6 +227,7 @@ export async function sendBlueBubblesMedia(params: {
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
+ const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });
let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
@@ -121,24 +261,27 @@ export async function sendBlueBubblesMedia(params: {
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
- const localPath = resolveLocalMediaPath(source);
- const fs = await import("node:fs/promises");
+ const localPath = expandHomePath(resolveLocalMediaPath(source));
+ const localFile = await assertLocalMediaPathAllowed({
+ localPath,
+ localRoots: mediaLocalRoots,
+ accountId,
+ });
if (typeof maxBytes === "number" && maxBytes > 0) {
- const stats = await fs.stat(localPath);
- assertMediaWithinLimit(stats.size, maxBytes);
+ assertMediaWithinLimit(localFile.sizeBytes, maxBytes);
}
- const data = await fs.readFile(localPath);
+ const data = localFile.data;
assertMediaWithinLimit(data.byteLength, maxBytes);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
- filePath: localPath,
+ filePath: localFile.realPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
- resolvedFilename = resolveFilenameFromSource(localPath);
+ resolvedFilename = resolveFilenameFromSource(localFile.realPath);
}
}
}
diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts
new file mode 100644
index 00000000000..56566f20981
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-normalize.ts
@@ -0,0 +1,796 @@
+import { normalizeBlueBubblesHandle } from "./targets.js";
+import type { BlueBubblesAttachment } from "./types.js";
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function readString(record: Record | null, key: string): string | undefined {
+ if (!record) {
+ return undefined;
+ }
+ const value = record[key];
+ return typeof value === "string" ? value : undefined;
+}
+
+function readNumber(record: Record | null, key: string): number | undefined {
+ if (!record) {
+ return undefined;
+ }
+ const value = record[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+function readBoolean(record: Record | null, key: string): boolean | undefined {
+ if (!record) {
+ return undefined;
+ }
+ const value = record[key];
+ return typeof value === "boolean" ? value : undefined;
+}
+
+function readNumberLike(record: Record | null, key: string): number | undefined {
+ if (!record) {
+ return undefined;
+ }
+ const value = record[key];
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Number.parseFloat(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ return undefined;
+}
+
+function extractAttachments(message: Record): BlueBubblesAttachment[] {
+ const raw = message["attachments"];
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+ const out: BlueBubblesAttachment[] = [];
+ for (const entry of raw) {
+ const record = asRecord(entry);
+ if (!record) {
+ continue;
+ }
+ out.push({
+ guid: readString(record, "guid"),
+ uti: readString(record, "uti"),
+ mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
+ transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
+ totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
+ height: readNumberLike(record, "height"),
+ width: readNumberLike(record, "width"),
+ originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
+ });
+ }
+ return out;
+}
+
+function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
+ if (attachments.length === 0) {
+ return "";
+ }
+ const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
+ const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
+ const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
+ const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
+ const tag = allImages
+ ? ""
+ : allVideos
+ ? ""
+ : allAudio
+ ? ""
+ : "";
+ const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
+ const suffix = attachments.length === 1 ? label : `${label}s`;
+ return `${tag} (${attachments.length} ${suffix})`;
+}
+
+export function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
+ const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
+ if (attachmentPlaceholder) {
+ return attachmentPlaceholder;
+ }
+ if (message.balloonBundleId) {
+ return "";
+ }
+ return "";
+}
+
+// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
+export function formatReplyTag(message: {
+ replyToId?: string;
+ replyToShortId?: string;
+}): string | null {
+ // Prefer short ID
+ const rawId = message.replyToShortId || message.replyToId;
+ if (!rawId) {
+ return null;
+ }
+ return `[[reply_to:${rawId}]]`;
+}
+
+function extractReplyMetadata(message: Record): {
+ replyToId?: string;
+ replyToBody?: string;
+ replyToSender?: string;
+} {
+ const replyRaw =
+ message["replyTo"] ??
+ message["reply_to"] ??
+ message["replyToMessage"] ??
+ message["reply_to_message"] ??
+ message["repliedMessage"] ??
+ message["quotedMessage"] ??
+ message["associatedMessage"] ??
+ message["reply"];
+ const replyRecord = asRecord(replyRaw);
+ const replyHandle =
+ asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
+ const replySenderRaw =
+ readString(replyHandle, "address") ??
+ readString(replyHandle, "handle") ??
+ readString(replyHandle, "id") ??
+ readString(replyRecord, "senderId") ??
+ readString(replyRecord, "sender") ??
+ readString(replyRecord, "from");
+ const normalizedSender = replySenderRaw
+ ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
+ : undefined;
+
+ const replyToBody =
+ readString(replyRecord, "text") ??
+ readString(replyRecord, "body") ??
+ readString(replyRecord, "message") ??
+ readString(replyRecord, "subject") ??
+ undefined;
+
+ const directReplyId =
+ readString(message, "replyToMessageGuid") ??
+ readString(message, "replyToGuid") ??
+ readString(message, "replyGuid") ??
+ readString(message, "selectedMessageGuid") ??
+ readString(message, "selectedMessageId") ??
+ readString(message, "replyToMessageId") ??
+ readString(message, "replyId") ??
+ readString(replyRecord, "guid") ??
+ readString(replyRecord, "id") ??
+ readString(replyRecord, "messageId");
+
+ const associatedType =
+ readNumberLike(message, "associatedMessageType") ??
+ readNumberLike(message, "associated_message_type");
+ const associatedGuid =
+ readString(message, "associatedMessageGuid") ??
+ readString(message, "associated_message_guid") ??
+ readString(message, "associatedMessageId");
+ const isReactionAssociation =
+ typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
+
+ const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
+ const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
+ const messageGuid = readString(message, "guid");
+ const fallbackReplyId =
+ !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
+ ? threadOriginatorGuid
+ : undefined;
+
+ return {
+ replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
+ replyToBody: replyToBody?.trim() || undefined,
+ replyToSender: normalizedSender || undefined,
+ };
+}
+
+function readFirstChatRecord(message: Record): Record | null {
+ const chats = message["chats"];
+ if (!Array.isArray(chats) || chats.length === 0) {
+ return null;
+ }
+ const first = chats[0];
+ return asRecord(first);
+}
+
+function extractSenderInfo(message: Record): {
+ senderId: string;
+ senderName?: string;
+} {
+ const handleValue = message.handle ?? message.sender;
+ const handle =
+ asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
+ const senderId =
+ readString(handle, "address") ??
+ readString(handle, "handle") ??
+ readString(handle, "id") ??
+ readString(message, "senderId") ??
+ readString(message, "sender") ??
+ readString(message, "from") ??
+ "";
+ const senderName =
+ readString(handle, "displayName") ??
+ readString(handle, "name") ??
+ readString(message, "senderName") ??
+ undefined;
+
+ return { senderId, senderName };
+}
+
+function extractChatContext(message: Record): {
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatId?: number;
+ chatName?: string;
+ isGroup: boolean;
+ participants: unknown[];
+} {
+ const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
+ const chatFromList = readFirstChatRecord(message);
+ const chatGuid =
+ readString(message, "chatGuid") ??
+ readString(message, "chat_guid") ??
+ readString(chat, "chatGuid") ??
+ readString(chat, "chat_guid") ??
+ readString(chat, "guid") ??
+ readString(chatFromList, "chatGuid") ??
+ readString(chatFromList, "chat_guid") ??
+ readString(chatFromList, "guid");
+ const chatIdentifier =
+ readString(message, "chatIdentifier") ??
+ readString(message, "chat_identifier") ??
+ readString(chat, "chatIdentifier") ??
+ readString(chat, "chat_identifier") ??
+ readString(chat, "identifier") ??
+ readString(chatFromList, "chatIdentifier") ??
+ readString(chatFromList, "chat_identifier") ??
+ readString(chatFromList, "identifier") ??
+ extractChatIdentifierFromChatGuid(chatGuid);
+ const chatId =
+ readNumberLike(message, "chatId") ??
+ readNumberLike(message, "chat_id") ??
+ readNumberLike(chat, "chatId") ??
+ readNumberLike(chat, "chat_id") ??
+ readNumberLike(chat, "id") ??
+ readNumberLike(chatFromList, "chatId") ??
+ readNumberLike(chatFromList, "chat_id") ??
+ readNumberLike(chatFromList, "id");
+ const chatName =
+ readString(message, "chatName") ??
+ readString(chat, "displayName") ??
+ readString(chat, "name") ??
+ readString(chatFromList, "displayName") ??
+ readString(chatFromList, "name") ??
+ undefined;
+
+ const chatParticipants = chat ? chat["participants"] : undefined;
+ const messageParticipants = message["participants"];
+ const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
+ const participants = Array.isArray(chatParticipants)
+ ? chatParticipants
+ : Array.isArray(messageParticipants)
+ ? messageParticipants
+ : Array.isArray(chatsParticipants)
+ ? chatsParticipants
+ : [];
+ const participantsCount = participants.length;
+ const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
+ const explicitIsGroup =
+ readBoolean(message, "isGroup") ??
+ readBoolean(message, "is_group") ??
+ readBoolean(chat, "isGroup") ??
+ readBoolean(message, "group");
+ const isGroup =
+ typeof groupFromChatGuid === "boolean"
+ ? groupFromChatGuid
+ : (explicitIsGroup ?? participantsCount > 2);
+
+ return {
+ chatGuid,
+ chatIdentifier,
+ chatId,
+ chatName,
+ isGroup,
+ participants,
+ };
+}
+
+function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
+ if (typeof entry === "string" || typeof entry === "number") {
+ const raw = String(entry).trim();
+ if (!raw) {
+ return null;
+ }
+ const normalized = normalizeBlueBubblesHandle(raw) || raw;
+ return normalized ? { id: normalized } : null;
+ }
+ const record = asRecord(entry);
+ if (!record) {
+ return null;
+ }
+ const nestedHandle =
+ asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
+ const idRaw =
+ readString(record, "address") ??
+ readString(record, "handle") ??
+ readString(record, "id") ??
+ readString(record, "phoneNumber") ??
+ readString(record, "phone_number") ??
+ readString(record, "email") ??
+ readString(nestedHandle, "address") ??
+ readString(nestedHandle, "handle") ??
+ readString(nestedHandle, "id");
+ const nameRaw =
+ readString(record, "displayName") ??
+ readString(record, "name") ??
+ readString(record, "title") ??
+ readString(nestedHandle, "displayName") ??
+ readString(nestedHandle, "name");
+ const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
+ if (!normalizedId) {
+ return null;
+ }
+ const name = nameRaw?.trim() || undefined;
+ return { id: normalizedId, name };
+}
+
+function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
+ if (!Array.isArray(raw) || raw.length === 0) {
+ return [];
+ }
+ const seen = new Set();
+ const output: BlueBubblesParticipant[] = [];
+ for (const entry of raw) {
+ const normalized = normalizeParticipantEntry(entry);
+ if (!normalized?.id) {
+ continue;
+ }
+ const key = normalized.id.toLowerCase();
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ output.push(normalized);
+ }
+ return output;
+}
+
+export function formatGroupMembers(params: {
+ participants?: BlueBubblesParticipant[];
+ fallback?: BlueBubblesParticipant;
+}): string | undefined {
+ const seen = new Set();
+ const ordered: BlueBubblesParticipant[] = [];
+ for (const entry of params.participants ?? []) {
+ if (!entry?.id) {
+ continue;
+ }
+ const key = entry.id.toLowerCase();
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ ordered.push(entry);
+ }
+ if (ordered.length === 0 && params.fallback?.id) {
+ ordered.push(params.fallback);
+ }
+ if (ordered.length === 0) {
+ return undefined;
+ }
+ return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
+}
+
+export function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
+ const guid = chatGuid?.trim();
+ if (!guid) {
+ return undefined;
+ }
+ const parts = guid.split(";");
+ if (parts.length >= 3) {
+ if (parts[1] === "+") {
+ return true;
+ }
+ if (parts[1] === "-") {
+ return false;
+ }
+ }
+ if (guid.includes(";+;")) {
+ return true;
+ }
+ if (guid.includes(";-;")) {
+ return false;
+ }
+ return undefined;
+}
+
+function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
+ const guid = chatGuid?.trim();
+ if (!guid) {
+ return undefined;
+ }
+ const parts = guid.split(";");
+ if (parts.length < 3) {
+ return undefined;
+ }
+ const identifier = parts[2]?.trim();
+ return identifier || undefined;
+}
+
+export function formatGroupAllowlistEntry(params: {
+ chatGuid?: string;
+ chatId?: number;
+ chatIdentifier?: string;
+}): string | null {
+ const guid = params.chatGuid?.trim();
+ if (guid) {
+ return `chat_guid:${guid}`;
+ }
+ const chatId = params.chatId;
+ if (typeof chatId === "number" && Number.isFinite(chatId)) {
+ return `chat_id:${chatId}`;
+ }
+ const identifier = params.chatIdentifier?.trim();
+ if (identifier) {
+ return `chat_identifier:${identifier}`;
+ }
+ return null;
+}
+
+export type BlueBubblesParticipant = {
+ id: string;
+ name?: string;
+};
+
+export type NormalizedWebhookMessage = {
+ text: string;
+ senderId: string;
+ senderName?: string;
+ messageId?: string;
+ timestamp?: number;
+ isGroup: boolean;
+ chatId?: number;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatName?: string;
+ fromMe?: boolean;
+ attachments?: BlueBubblesAttachment[];
+ balloonBundleId?: string;
+ associatedMessageGuid?: string;
+ associatedMessageType?: number;
+ associatedMessageEmoji?: string;
+ isTapback?: boolean;
+ participants?: BlueBubblesParticipant[];
+ replyToId?: string;
+ replyToBody?: string;
+ replyToSender?: string;
+};
+
+export type NormalizedWebhookReaction = {
+ action: "added" | "removed";
+ emoji: string;
+ senderId: string;
+ senderName?: string;
+ messageId: string;
+ timestamp?: number;
+ isGroup: boolean;
+ chatId?: number;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatName?: string;
+ fromMe?: boolean;
+};
+
+const REACTION_TYPE_MAP = new Map([
+ [2000, { emoji: "❤️", action: "added" }],
+ [2001, { emoji: "👍", action: "added" }],
+ [2002, { emoji: "👎", action: "added" }],
+ [2003, { emoji: "😂", action: "added" }],
+ [2004, { emoji: "‼️", action: "added" }],
+ [2005, { emoji: "❓", action: "added" }],
+ [3000, { emoji: "❤️", action: "removed" }],
+ [3001, { emoji: "👍", action: "removed" }],
+ [3002, { emoji: "👎", action: "removed" }],
+ [3003, { emoji: "😂", action: "removed" }],
+ [3004, { emoji: "‼️", action: "removed" }],
+ [3005, { emoji: "❓", action: "removed" }],
+]);
+
+// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
+const TAPBACK_TEXT_MAP = new Map([
+ ["loved", { emoji: "❤️", action: "added" }],
+ ["liked", { emoji: "👍", action: "added" }],
+ ["disliked", { emoji: "👎", action: "added" }],
+ ["laughed at", { emoji: "😂", action: "added" }],
+ ["emphasized", { emoji: "‼️", action: "added" }],
+ ["questioned", { emoji: "❓", action: "added" }],
+ // Removal patterns (e.g., "Removed a heart from")
+ ["removed a heart from", { emoji: "❤️", action: "removed" }],
+ ["removed a like from", { emoji: "👍", action: "removed" }],
+ ["removed a dislike from", { emoji: "👎", action: "removed" }],
+ ["removed a laugh from", { emoji: "😂", action: "removed" }],
+ ["removed an emphasis from", { emoji: "‼️", action: "removed" }],
+ ["removed a question from", { emoji: "❓", action: "removed" }],
+]);
+
+const TAPBACK_EMOJI_REGEX =
+ /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
+
+function extractFirstEmoji(text: string): string | null {
+ const match = text.match(TAPBACK_EMOJI_REGEX);
+ return match ? match[0] : null;
+}
+
+function extractQuotedTapbackText(text: string): string | null {
+ const match = text.match(/[“"]([^”"]+)[”"]/s);
+ return match ? match[1] : null;
+}
+
+function isTapbackAssociatedType(type: number | undefined): boolean {
+ return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
+}
+
+function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
+ if (typeof type !== "number" || !Number.isFinite(type)) {
+ return undefined;
+ }
+ if (type >= 3000 && type < 4000) {
+ return "removed";
+ }
+ if (type >= 2000 && type < 3000) {
+ return "added";
+ }
+ return undefined;
+}
+
+export function resolveTapbackContext(message: NormalizedWebhookMessage): {
+ emojiHint?: string;
+ actionHint?: "added" | "removed";
+ replyToId?: string;
+} | null {
+ const associatedType = message.associatedMessageType;
+ const hasTapbackType = isTapbackAssociatedType(associatedType);
+ const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
+ if (!hasTapbackType && !hasTapbackMarker) {
+ return null;
+ }
+ const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
+ const actionHint = resolveTapbackActionHint(associatedType);
+ const emojiHint =
+ message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
+ return { emojiHint, actionHint, replyToId };
+}
+
+// Detects tapback text patterns like 'Loved "message"' and converts to structured format
+export function parseTapbackText(params: {
+ text: string;
+ emojiHint?: string;
+ actionHint?: "added" | "removed";
+ requireQuoted?: boolean;
+}): {
+ emoji: string;
+ action: "added" | "removed";
+ quotedText: string;
+} | null {
+ const trimmed = params.text.trim();
+ const lower = trimmed.toLowerCase();
+ if (!trimmed) {
+ return null;
+ }
+
+ for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
+ if (lower.startsWith(pattern)) {
+ // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
+ const afterPattern = trimmed.slice(pattern.length).trim();
+ if (params.requireQuoted) {
+ const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
+ if (!strictMatch) {
+ return null;
+ }
+ return { emoji, action, quotedText: strictMatch[1] };
+ }
+ const quotedText =
+ extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
+ return { emoji, action, quotedText };
+ }
+ }
+
+ if (lower.startsWith("reacted")) {
+ const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
+ if (!emoji) {
+ return null;
+ }
+ const quotedText = extractQuotedTapbackText(trimmed);
+ if (params.requireQuoted && !quotedText) {
+ return null;
+ }
+ const fallback = trimmed.slice("reacted".length).trim();
+ return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
+ }
+
+ if (lower.startsWith("removed")) {
+ const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
+ if (!emoji) {
+ return null;
+ }
+ const quotedText = extractQuotedTapbackText(trimmed);
+ if (params.requireQuoted && !quotedText) {
+ return null;
+ }
+ const fallback = trimmed.slice("removed".length).trim();
+ return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
+ }
+ return null;
+}
+
+function extractMessagePayload(payload: Record): Record | null {
+ const dataRaw = payload.data ?? payload.payload ?? payload.event;
+ const data =
+ asRecord(dataRaw) ??
+ (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
+ const messageRaw = payload.message ?? data?.message ?? data;
+ const message =
+ asRecord(messageRaw) ??
+ (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
+ if (!message) {
+ return null;
+ }
+ return message;
+}
+
+export function normalizeWebhookMessage(
+ payload: Record,
+): NormalizedWebhookMessage | null {
+ const message = extractMessagePayload(payload);
+ if (!message) {
+ return null;
+ }
+
+ const text =
+ readString(message, "text") ??
+ readString(message, "body") ??
+ readString(message, "subject") ??
+ "";
+
+ const { senderId, senderName } = extractSenderInfo(message);
+ const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
+ extractChatContext(message);
+ const normalizedParticipants = normalizeParticipantList(participants);
+
+ const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
+ const messageId =
+ readString(message, "guid") ??
+ readString(message, "id") ??
+ readString(message, "messageId") ??
+ undefined;
+ const balloonBundleId = readString(message, "balloonBundleId");
+ const associatedMessageGuid =
+ readString(message, "associatedMessageGuid") ??
+ readString(message, "associated_message_guid") ??
+ readString(message, "associatedMessageId") ??
+ undefined;
+ const associatedMessageType =
+ readNumberLike(message, "associatedMessageType") ??
+ readNumberLike(message, "associated_message_type");
+ const associatedMessageEmoji =
+ readString(message, "associatedMessageEmoji") ??
+ readString(message, "associated_message_emoji") ??
+ readString(message, "reactionEmoji") ??
+ readString(message, "reaction_emoji") ??
+ undefined;
+ const isTapback =
+ readBoolean(message, "isTapback") ??
+ readBoolean(message, "is_tapback") ??
+ readBoolean(message, "tapback") ??
+ undefined;
+
+ const timestampRaw =
+ readNumber(message, "date") ??
+ readNumber(message, "dateCreated") ??
+ readNumber(message, "timestamp");
+ const timestamp =
+ typeof timestampRaw === "number"
+ ? timestampRaw > 1_000_000_000_000
+ ? timestampRaw
+ : timestampRaw * 1000
+ : undefined;
+
+ const normalizedSender = normalizeBlueBubblesHandle(senderId);
+ if (!normalizedSender) {
+ return null;
+ }
+ const replyMetadata = extractReplyMetadata(message);
+
+ return {
+ text,
+ senderId: normalizedSender,
+ senderName,
+ messageId,
+ timestamp,
+ isGroup,
+ chatId,
+ chatGuid,
+ chatIdentifier,
+ chatName,
+ fromMe,
+ attachments: extractAttachments(message),
+ balloonBundleId,
+ associatedMessageGuid,
+ associatedMessageType,
+ associatedMessageEmoji,
+ isTapback,
+ participants: normalizedParticipants,
+ replyToId: replyMetadata.replyToId,
+ replyToBody: replyMetadata.replyToBody,
+ replyToSender: replyMetadata.replyToSender,
+ };
+}
+
+export function normalizeWebhookReaction(
+ payload: Record,
+): NormalizedWebhookReaction | null {
+ const message = extractMessagePayload(payload);
+ if (!message) {
+ return null;
+ }
+
+ const associatedGuid =
+ readString(message, "associatedMessageGuid") ??
+ readString(message, "associated_message_guid") ??
+ readString(message, "associatedMessageId");
+ const associatedType =
+ readNumberLike(message, "associatedMessageType") ??
+ readNumberLike(message, "associated_message_type");
+ if (!associatedGuid || associatedType === undefined) {
+ return null;
+ }
+
+ const mapping = REACTION_TYPE_MAP.get(associatedType);
+ const associatedEmoji =
+ readString(message, "associatedMessageEmoji") ??
+ readString(message, "associated_message_emoji") ??
+ readString(message, "reactionEmoji") ??
+ readString(message, "reaction_emoji");
+ const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
+ const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
+
+ const { senderId, senderName } = extractSenderInfo(message);
+ const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
+
+ const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
+ const timestampRaw =
+ readNumberLike(message, "date") ??
+ readNumberLike(message, "dateCreated") ??
+ readNumberLike(message, "timestamp");
+ const timestamp =
+ typeof timestampRaw === "number"
+ ? timestampRaw > 1_000_000_000_000
+ ? timestampRaw
+ : timestampRaw * 1000
+ : undefined;
+
+ const normalizedSender = normalizeBlueBubblesHandle(senderId);
+ if (!normalizedSender) {
+ return null;
+ }
+
+ return {
+ action,
+ emoji,
+ senderId: normalizedSender,
+ senderName,
+ messageId: associatedGuid,
+ timestamp,
+ isGroup,
+ chatId,
+ chatGuid,
+ chatIdentifier,
+ chatName,
+ fromMe,
+ };
+}
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
new file mode 100644
index 00000000000..1b5e80352e6
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -0,0 +1,1007 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import {
+ createReplyPrefixOptions,
+ logAckFailure,
+ logInboundDrop,
+ logTypingFailure,
+ resolveAckReaction,
+ resolveControlCommandGate,
+} from "openclaw/plugin-sdk";
+import { downloadBlueBubblesAttachment } from "./attachments.js";
+import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
+import { sendBlueBubblesMedia } from "./media-send.js";
+import {
+ buildMessagePlaceholder,
+ formatGroupAllowlistEntry,
+ formatGroupMembers,
+ formatReplyTag,
+ parseTapbackText,
+ resolveGroupFlagFromChatGuid,
+ resolveTapbackContext,
+ type NormalizedWebhookMessage,
+ type NormalizedWebhookReaction,
+} from "./monitor-normalize.js";
+import {
+ getShortIdForUuid,
+ rememberBlueBubblesReplyCache,
+ resolveBlueBubblesMessageId,
+ resolveReplyContextFromCache,
+} from "./monitor-reply-cache.js";
+import type {
+ BlueBubblesCoreRuntime,
+ BlueBubblesRuntimeEnv,
+ WebhookTarget,
+} from "./monitor-shared.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
+import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
+import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
+
+const DEFAULT_TEXT_LIMIT = 4000;
+const invalidAckReactions = new Set();
+const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
+
+export function logVerbose(
+ core: BlueBubblesCoreRuntime,
+ runtime: BlueBubblesRuntimeEnv,
+ message: string,
+): void {
+ if (core.logging.shouldLogVerbose()) {
+ runtime.log?.(`[bluebubbles] ${message}`);
+ }
+}
+
+function logGroupAllowlistHint(params: {
+ runtime: BlueBubblesRuntimeEnv;
+ reason: string;
+ entry: string | null;
+ chatName?: string;
+ accountId?: string;
+}): void {
+ const log = params.runtime.log ?? console.log;
+ const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
+ const accountHint = params.accountId
+ ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
+ : "";
+ if (params.entry) {
+ log(
+ `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
+ `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
+ );
+ log(
+ `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
+ );
+ return;
+ }
+ log(
+ `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
+ `channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
+ `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
+ );
+}
+
+function resolveBlueBubblesAckReaction(params: {
+ cfg: OpenClawConfig;
+ agentId: string;
+ core: BlueBubblesCoreRuntime;
+ runtime: BlueBubblesRuntimeEnv;
+}): string | null {
+ const raw = resolveAckReaction(params.cfg, params.agentId).trim();
+ if (!raw) {
+ return null;
+ }
+ try {
+ normalizeBlueBubblesReactionInput(raw);
+ return raw;
+ } catch {
+ const key = raw.toLowerCase();
+ if (!invalidAckReactions.has(key)) {
+ invalidAckReactions.add(key);
+ logVerbose(
+ params.core,
+ params.runtime,
+ `ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
+ );
+ }
+ return null;
+ }
+}
+
+export async function processMessage(
+ message: NormalizedWebhookMessage,
+ target: WebhookTarget,
+): Promise {
+ const { account, config, runtime, core, statusSink } = target;
+ const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false;
+
+ const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
+ const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
+
+ const text = message.text.trim();
+ const attachments = message.attachments ?? [];
+ const placeholder = buildMessagePlaceholder(message);
+ // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
+ // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
+ const tapbackContext = resolveTapbackContext(message);
+ const tapbackParsed = parseTapbackText({
+ text,
+ emojiHint: tapbackContext?.emojiHint,
+ actionHint: tapbackContext?.actionHint,
+ requireQuoted: !tapbackContext,
+ });
+ const isTapbackMessage = Boolean(tapbackParsed);
+ const rawBody = tapbackParsed
+ ? tapbackParsed.action === "removed"
+ ? `removed ${tapbackParsed.emoji} reaction`
+ : `reacted with ${tapbackParsed.emoji}`
+ : text || placeholder;
+
+ const cacheMessageId = message.messageId?.trim();
+ let messageShortId: string | undefined;
+ const cacheInboundMessage = () => {
+ if (!cacheMessageId) {
+ return;
+ }
+ const cacheEntry = rememberBlueBubblesReplyCache({
+ accountId: account.accountId,
+ messageId: cacheMessageId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ senderLabel: message.fromMe ? "me" : message.senderId,
+ body: rawBody,
+ timestamp: message.timestamp ?? Date.now(),
+ });
+ messageShortId = cacheEntry.shortId;
+ };
+
+ if (message.fromMe) {
+ // Cache from-me messages so reply context can resolve sender/body.
+ cacheInboundMessage();
+ return;
+ }
+
+ if (!rawBody) {
+ logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
+ return;
+ }
+ logVerbose(
+ core,
+ runtime,
+ `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
+ );
+
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
+ const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
+ const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
+ const storeAllowFrom = await core.channel.pairing
+ .readAllowFromStore("bluebubbles")
+ .catch(() => []);
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
+ .map((entry) => String(entry).trim())
+ .filter(Boolean);
+ const effectiveGroupAllowFrom = [
+ ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
+ ...storeAllowFrom,
+ ]
+ .map((entry) => String(entry).trim())
+ .filter(Boolean);
+ const groupAllowEntry = formatGroupAllowlistEntry({
+ chatGuid: message.chatGuid,
+ chatId: message.chatId ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ });
+ const groupName = message.chatName?.trim() || undefined;
+
+ if (isGroup) {
+ if (groupPolicy === "disabled") {
+ logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
+ logGroupAllowlistHint({
+ runtime,
+ reason: "groupPolicy=disabled",
+ entry: groupAllowEntry,
+ chatName: groupName,
+ accountId: account.accountId,
+ });
+ return;
+ }
+ if (groupPolicy === "allowlist") {
+ if (effectiveGroupAllowFrom.length === 0) {
+ logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
+ logGroupAllowlistHint({
+ runtime,
+ reason: "groupPolicy=allowlist (empty allowlist)",
+ entry: groupAllowEntry,
+ chatName: groupName,
+ accountId: account.accountId,
+ });
+ return;
+ }
+ const allowed = isAllowedBlueBubblesSender({
+ allowFrom: effectiveGroupAllowFrom,
+ sender: message.senderId,
+ chatId: message.chatId ?? undefined,
+ chatGuid: message.chatGuid ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ });
+ if (!allowed) {
+ logVerbose(
+ core,
+ runtime,
+ `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
+ );
+ logVerbose(
+ core,
+ runtime,
+ `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
+ );
+ logGroupAllowlistHint({
+ runtime,
+ reason: "groupPolicy=allowlist (not allowlisted)",
+ entry: groupAllowEntry,
+ chatName: groupName,
+ accountId: account.accountId,
+ });
+ return;
+ }
+ }
+ } else {
+ if (dmPolicy === "disabled") {
+ logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
+ logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
+ return;
+ }
+ if (dmPolicy !== "open") {
+ const allowed = isAllowedBlueBubblesSender({
+ allowFrom: effectiveAllowFrom,
+ sender: message.senderId,
+ chatId: message.chatId ?? undefined,
+ chatGuid: message.chatGuid ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ });
+ if (!allowed) {
+ if (dmPolicy === "pairing") {
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
+ channel: "bluebubbles",
+ id: message.senderId,
+ meta: { name: message.senderName },
+ });
+ runtime.log?.(
+ `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
+ );
+ if (created) {
+ logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
+ try {
+ await sendMessageBlueBubbles(
+ message.senderId,
+ core.channel.pairing.buildPairingReply({
+ channel: "bluebubbles",
+ idLine: `Your BlueBubbles sender id: ${message.senderId}`,
+ code,
+ }),
+ { cfg: config, accountId: account.accountId },
+ );
+ statusSink?.({ lastOutboundAt: Date.now() });
+ } catch (err) {
+ logVerbose(
+ core,
+ runtime,
+ `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
+ );
+ runtime.error?.(
+ `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
+ );
+ }
+ }
+ } else {
+ logVerbose(
+ core,
+ runtime,
+ `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
+ );
+ logVerbose(
+ core,
+ runtime,
+ `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
+ );
+ }
+ return;
+ }
+ }
+ }
+
+ const chatId = message.chatId ?? undefined;
+ const chatGuid = message.chatGuid ?? undefined;
+ const chatIdentifier = message.chatIdentifier ?? undefined;
+ const peerId = isGroup
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
+ : message.senderId;
+
+ const route = core.channel.routing.resolveAgentRoute({
+ cfg: config,
+ channel: "bluebubbles",
+ accountId: account.accountId,
+ peer: {
+ kind: isGroup ? "group" : "direct",
+ id: peerId,
+ },
+ });
+
+ // Mention gating for group chats (parity with iMessage/WhatsApp)
+ const messageText = text;
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
+ const wasMentioned = isGroup
+ ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
+ : true;
+ const canDetectMention = mentionRegexes.length > 0;
+ const requireMention = core.channel.groups.resolveRequireMention({
+ cfg: config,
+ channel: "bluebubbles",
+ groupId: peerId,
+ accountId: account.accountId,
+ });
+
+ // Command gating (parity with iMessage/WhatsApp)
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
+ const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
+ const ownerAllowedForCommands =
+ effectiveAllowFrom.length > 0
+ ? isAllowedBlueBubblesSender({
+ allowFrom: effectiveAllowFrom,
+ sender: message.senderId,
+ chatId: message.chatId ?? undefined,
+ chatGuid: message.chatGuid ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ })
+ : false;
+ const groupAllowedForCommands =
+ effectiveGroupAllowFrom.length > 0
+ ? isAllowedBlueBubblesSender({
+ allowFrom: effectiveGroupAllowFrom,
+ sender: message.senderId,
+ chatId: message.chatId ?? undefined,
+ chatGuid: message.chatGuid ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ })
+ : false;
+ const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
+ const commandGate = resolveControlCommandGate({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
+ { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
+ ],
+ allowTextCommands: true,
+ hasControlCommand: hasControlCmd,
+ });
+ const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
+
+ // Block control commands from unauthorized senders in groups
+ if (isGroup && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ reason: "control command (unauthorized)",
+ target: message.senderId,
+ });
+ return;
+ }
+
+ // Allow control commands to bypass mention gating when authorized (parity with iMessage)
+ const shouldBypassMention =
+ isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
+ const effectiveWasMentioned = wasMentioned || shouldBypassMention;
+
+ // Skip group messages that require mention but weren't mentioned
+ if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
+ logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
+ return;
+ }
+
+ // Cache allowed inbound messages so later replies can resolve sender/body without
+ // surfacing dropped content (allowlist/mention/command gating).
+ cacheInboundMessage();
+
+ const baseUrl = account.config.serverUrl?.trim();
+ const password = account.config.password?.trim();
+ const maxBytes =
+ account.config.mediaMaxMb && account.config.mediaMaxMb > 0
+ ? account.config.mediaMaxMb * 1024 * 1024
+ : 8 * 1024 * 1024;
+
+ let mediaUrls: string[] = [];
+ let mediaPaths: string[] = [];
+ let mediaTypes: string[] = [];
+ if (attachments.length > 0) {
+ if (!baseUrl || !password) {
+ logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
+ } else {
+ for (const attachment of attachments) {
+ if (!attachment.guid) {
+ continue;
+ }
+ if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
+ logVerbose(
+ core,
+ runtime,
+ `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
+ );
+ continue;
+ }
+ try {
+ const downloaded = await downloadBlueBubblesAttachment(attachment, {
+ cfg: config,
+ accountId: account.accountId,
+ maxBytes,
+ });
+ const saved = await core.channel.media.saveMediaBuffer(
+ Buffer.from(downloaded.buffer),
+ downloaded.contentType,
+ "inbound",
+ maxBytes,
+ );
+ mediaPaths.push(saved.path);
+ mediaUrls.push(saved.path);
+ if (saved.contentType) {
+ mediaTypes.push(saved.contentType);
+ }
+ } catch (err) {
+ logVerbose(
+ core,
+ runtime,
+ `attachment download failed guid=${attachment.guid} err=${String(err)}`,
+ );
+ }
+ }
+ }
+ }
+ let replyToId = message.replyToId;
+ let replyToBody = message.replyToBody;
+ let replyToSender = message.replyToSender;
+ let replyToShortId: string | undefined;
+
+ if (isTapbackMessage && tapbackContext?.replyToId) {
+ replyToId = tapbackContext.replyToId;
+ }
+
+ if (replyToId) {
+ const cached = resolveReplyContextFromCache({
+ accountId: account.accountId,
+ replyToId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ });
+ if (cached) {
+ if (!replyToBody && cached.body) {
+ replyToBody = cached.body;
+ }
+ if (!replyToSender && cached.senderLabel) {
+ replyToSender = cached.senderLabel;
+ }
+ replyToShortId = cached.shortId;
+ if (core.logging.shouldLogVerbose()) {
+ const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
+ logVerbose(
+ core,
+ runtime,
+ `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
+ );
+ }
+ }
+ }
+
+ // If no cached short ID, try to get one from the UUID directly
+ if (replyToId && !replyToShortId) {
+ replyToShortId = getShortIdForUuid(replyToId);
+ }
+
+ // Use inline [[reply_to:N]] tag format
+ // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
+ // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
+ const replyTag = formatReplyTag({ replyToId, replyToShortId });
+ const baseBody = replyTag
+ ? isTapbackMessage
+ ? `${rawBody} ${replyTag}`
+ : `${replyTag} ${rawBody}`
+ : rawBody;
+ // Build fromLabel the same way as iMessage/Signal (formatInboundFromLabel):
+ // group label + id for groups, sender for DMs.
+ // The sender identity is included in the envelope body via formatInboundEnvelope.
+ const senderLabel = message.senderName || `user:${message.senderId}`;
+ const fromLabel = isGroup
+ ? `${message.chatName?.trim() || "Group"} id:${peerId}`
+ : senderLabel !== message.senderId
+ ? `${senderLabel} id:${message.senderId}`
+ : senderLabel;
+ const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
+ const groupMembers = isGroup
+ ? formatGroupMembers({
+ participants: message.participants,
+ fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
+ })
+ : undefined;
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
+ agentId: route.agentId,
+ });
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
+ storePath,
+ sessionKey: route.sessionKey,
+ });
+ const body = core.channel.reply.formatInboundEnvelope({
+ channel: "BlueBubbles",
+ from: fromLabel,
+ timestamp: message.timestamp,
+ previousTimestamp,
+ envelope: envelopeOptions,
+ body: baseBody,
+ chatType: isGroup ? "group" : "direct",
+ sender: { name: message.senderName || undefined, id: message.senderId },
+ });
+ let chatGuidForActions = chatGuid;
+ if (!chatGuidForActions && baseUrl && password) {
+ const resolveTarget =
+ isGroup && (chatId || chatIdentifier)
+ ? chatId
+ ? ({ kind: "chat_id", chatId } as const)
+ : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
+ : ({ kind: "handle", address: message.senderId } as const);
+ if (resolveTarget.kind !== "chat_identifier" || resolveTarget.chatIdentifier) {
+ chatGuidForActions =
+ (await resolveChatGuidForTarget({
+ baseUrl,
+ password,
+ target: resolveTarget,
+ })) ?? undefined;
+ }
+ }
+
+ const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
+ const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
+ const ackReactionValue = resolveBlueBubblesAckReaction({
+ cfg: config,
+ agentId: route.agentId,
+ core,
+ runtime,
+ });
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReactionValue &&
+ core.channel.reactions.shouldAckReaction({
+ scope: ackReactionScope,
+ isDirect: !isGroup,
+ isGroup,
+ isMentionableGroup: isGroup,
+ requireMention: Boolean(requireMention),
+ canDetectMention,
+ effectiveWasMentioned,
+ shouldBypassMention,
+ }),
+ );
+ const ackMessageId = message.messageId?.trim() || "";
+ const ackReactionPromise =
+ shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
+ ? sendBlueBubblesReaction({
+ chatGuid: chatGuidForActions,
+ messageGuid: ackMessageId,
+ emoji: ackReactionValue,
+ opts: { cfg: config, accountId: account.accountId },
+ }).then(
+ () => true,
+ (err) => {
+ logVerbose(
+ core,
+ runtime,
+ `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
+ );
+ return false;
+ },
+ )
+ : null;
+
+ // Respect sendReadReceipts config (parity with WhatsApp)
+ const sendReadReceipts = account.config.sendReadReceipts !== false;
+ if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
+ try {
+ await markBlueBubblesChatRead(chatGuidForActions, {
+ cfg: config,
+ accountId: account.accountId,
+ });
+ logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
+ } catch (err) {
+ runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
+ }
+ } else if (!sendReadReceipts) {
+ logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
+ } else {
+ logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
+ }
+
+ const outboundTarget = isGroup
+ ? formatBlueBubblesChatTarget({
+ chatId,
+ chatGuid: chatGuidForActions ?? chatGuid,
+ chatIdentifier,
+ }) || peerId
+ : chatGuidForActions
+ ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
+ : message.senderId;
+
+ const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
+ const trimmed = messageId?.trim();
+ if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
+ return;
+ }
+ // Cache outbound message to get short ID
+ const cacheEntry = rememberBlueBubblesReplyCache({
+ accountId: account.accountId,
+ messageId: trimmed,
+ chatGuid: chatGuidForActions ?? chatGuid,
+ chatIdentifier,
+ chatId,
+ senderLabel: "me",
+ body: snippet ?? "",
+ timestamp: Date.now(),
+ });
+ const displayId = cacheEntry.shortId || trimmed;
+ const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
+ core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
+ sessionKey: route.sessionKey,
+ contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
+ });
+ };
+ const sanitizeReplyDirectiveText = (value: string): string => {
+ if (privateApiEnabled) {
+ return value;
+ }
+ return value
+ .replace(REPLY_DIRECTIVE_TAG_RE, " ")
+ .replace(/[ \t]+/g, " ")
+ .trim();
+ };
+
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
+ Body: body,
+ BodyForAgent: rawBody,
+ RawBody: rawBody,
+ CommandBody: rawBody,
+ BodyForCommands: rawBody,
+ MediaUrl: mediaUrls[0],
+ MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
+ MediaPath: mediaPaths[0],
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
+ MediaType: mediaTypes[0],
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
+ From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
+ To: `bluebubbles:${outboundTarget}`,
+ SessionKey: route.sessionKey,
+ AccountId: route.accountId,
+ ChatType: isGroup ? "group" : "direct",
+ ConversationLabel: fromLabel,
+ // Use short ID for token savings (agent can use this to reference the message)
+ ReplyToId: replyToShortId || replyToId,
+ ReplyToIdFull: replyToId,
+ ReplyToBody: replyToBody,
+ ReplyToSender: replyToSender,
+ GroupSubject: groupSubject,
+ GroupMembers: groupMembers,
+ SenderName: message.senderName || undefined,
+ SenderId: message.senderId,
+ Provider: "bluebubbles",
+ Surface: "bluebubbles",
+ // Use short ID for token savings (agent can use this to reference the message)
+ MessageSid: messageShortId || message.messageId,
+ MessageSidFull: message.messageId,
+ Timestamp: message.timestamp,
+ OriginatingChannel: "bluebubbles",
+ OriginatingTo: `bluebubbles:${outboundTarget}`,
+ WasMentioned: effectiveWasMentioned,
+ CommandAuthorized: commandAuthorized,
+ });
+
+ let sentMessage = false;
+ let streamingActive = false;
+ let typingRestartTimer: NodeJS.Timeout | undefined;
+ const typingRestartDelayMs = 150;
+ const clearTypingRestartTimer = () => {
+ if (typingRestartTimer) {
+ clearTimeout(typingRestartTimer);
+ typingRestartTimer = undefined;
+ }
+ };
+ const restartTypingSoon = () => {
+ if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
+ return;
+ }
+ clearTypingRestartTimer();
+ typingRestartTimer = setTimeout(() => {
+ typingRestartTimer = undefined;
+ if (!streamingActive) {
+ return;
+ }
+ sendBlueBubblesTyping(chatGuidForActions, true, {
+ cfg: config,
+ accountId: account.accountId,
+ }).catch((err) => {
+ runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
+ });
+ }, typingRestartDelayMs);
+ };
+ try {
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
+ cfg: config,
+ agentId: route.agentId,
+ channel: "bluebubbles",
+ accountId: account.accountId,
+ });
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
+ ctx: ctxPayload,
+ cfg: config,
+ dispatcherOptions: {
+ ...prefixOptions,
+ deliver: async (payload, info) => {
+ const rawReplyToId =
+ privateApiEnabled && typeof payload.replyToId === "string"
+ ? payload.replyToId.trim()
+ : "";
+ // Resolve short ID (e.g., "5") to full UUID
+ const replyToMessageGuid = rawReplyToId
+ ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
+ : "";
+ const mediaList = payload.mediaUrls?.length
+ ? payload.mediaUrls
+ : payload.mediaUrl
+ ? [payload.mediaUrl]
+ : [];
+ if (mediaList.length > 0) {
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
+ cfg: config,
+ channel: "bluebubbles",
+ accountId: account.accountId,
+ });
+ const text = sanitizeReplyDirectiveText(
+ core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
+ );
+ let first = true;
+ for (const mediaUrl of mediaList) {
+ const caption = first ? text : undefined;
+ first = false;
+ const result = await sendBlueBubblesMedia({
+ cfg: config,
+ to: outboundTarget,
+ mediaUrl,
+ caption: caption ?? undefined,
+ replyToId: replyToMessageGuid || null,
+ accountId: account.accountId,
+ });
+ const cachedBody = (caption ?? "").trim() || "";
+ maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
+ sentMessage = true;
+ statusSink?.({ lastOutboundAt: Date.now() });
+ if (info.kind === "block") {
+ restartTypingSoon();
+ }
+ }
+ return;
+ }
+
+ const textLimit =
+ account.config.textChunkLimit && account.config.textChunkLimit > 0
+ ? account.config.textChunkLimit
+ : DEFAULT_TEXT_LIMIT;
+ const chunkMode = account.config.chunkMode ?? "length";
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
+ cfg: config,
+ channel: "bluebubbles",
+ accountId: account.accountId,
+ });
+ const text = sanitizeReplyDirectiveText(
+ core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
+ );
+ const chunks =
+ chunkMode === "newline"
+ ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
+ : core.channel.text.chunkMarkdownText(text, textLimit);
+ if (!chunks.length && text) {
+ chunks.push(text);
+ }
+ if (!chunks.length) {
+ return;
+ }
+ for (const chunk of chunks) {
+ const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
+ cfg: config,
+ accountId: account.accountId,
+ replyToMessageGuid: replyToMessageGuid || undefined,
+ });
+ maybeEnqueueOutboundMessageId(result.messageId, chunk);
+ sentMessage = true;
+ statusSink?.({ lastOutboundAt: Date.now() });
+ if (info.kind === "block") {
+ restartTypingSoon();
+ }
+ }
+ },
+ onReplyStart: async () => {
+ if (!chatGuidForActions) {
+ return;
+ }
+ if (!baseUrl || !password) {
+ return;
+ }
+ streamingActive = true;
+ clearTypingRestartTimer();
+ try {
+ await sendBlueBubblesTyping(chatGuidForActions, true, {
+ cfg: config,
+ accountId: account.accountId,
+ });
+ } catch (err) {
+ runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
+ }
+ },
+ onIdle: async () => {
+ if (!chatGuidForActions) {
+ return;
+ }
+ if (!baseUrl || !password) {
+ return;
+ }
+ // Intentionally no-op for block streaming. We stop typing in finally
+ // after the run completes to avoid flicker between paragraph blocks.
+ },
+ onError: (err, info) => {
+ runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
+ },
+ },
+ replyOptions: {
+ onModelSelected,
+ disableBlockStreaming:
+ typeof account.config.blockStreaming === "boolean"
+ ? !account.config.blockStreaming
+ : undefined,
+ },
+ });
+ } finally {
+ const shouldStopTyping =
+ Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
+ streamingActive = false;
+ clearTypingRestartTimer();
+ if (sentMessage && chatGuidForActions && ackMessageId) {
+ core.channel.reactions.removeAckReactionAfterReply({
+ removeAfterReply: removeAckAfterReply,
+ ackReactionPromise,
+ ackReactionValue: ackReactionValue ?? null,
+ remove: () =>
+ sendBlueBubblesReaction({
+ chatGuid: chatGuidForActions,
+ messageGuid: ackMessageId,
+ emoji: ackReactionValue ?? "",
+ remove: true,
+ opts: { cfg: config, accountId: account.accountId },
+ }),
+ onError: (err) => {
+ logAckFailure({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ target: `${chatGuidForActions}/${ackMessageId}`,
+ error: err,
+ });
+ },
+ });
+ }
+ if (shouldStopTyping && chatGuidForActions) {
+ // Stop typing after streaming completes to avoid a stuck indicator.
+ sendBlueBubblesTyping(chatGuidForActions, false, {
+ cfg: config,
+ accountId: account.accountId,
+ }).catch((err) => {
+ logTypingFailure({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ action: "stop",
+ target: chatGuidForActions,
+ error: err,
+ });
+ });
+ }
+ }
+}
+
+export async function processReaction(
+ reaction: NormalizedWebhookReaction,
+ target: WebhookTarget,
+): Promise {
+ const { account, config, runtime, core } = target;
+ if (reaction.fromMe) {
+ return;
+ }
+
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
+ const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
+ const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
+ const storeAllowFrom = await core.channel.pairing
+ .readAllowFromStore("bluebubbles")
+ .catch(() => []);
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
+ .map((entry) => String(entry).trim())
+ .filter(Boolean);
+ const effectiveGroupAllowFrom = [
+ ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
+ ...storeAllowFrom,
+ ]
+ .map((entry) => String(entry).trim())
+ .filter(Boolean);
+
+ if (reaction.isGroup) {
+ if (groupPolicy === "disabled") {
+ return;
+ }
+ if (groupPolicy === "allowlist") {
+ if (effectiveGroupAllowFrom.length === 0) {
+ return;
+ }
+ const allowed = isAllowedBlueBubblesSender({
+ allowFrom: effectiveGroupAllowFrom,
+ sender: reaction.senderId,
+ chatId: reaction.chatId ?? undefined,
+ chatGuid: reaction.chatGuid ?? undefined,
+ chatIdentifier: reaction.chatIdentifier ?? undefined,
+ });
+ if (!allowed) {
+ return;
+ }
+ }
+ } else {
+ if (dmPolicy === "disabled") {
+ return;
+ }
+ if (dmPolicy !== "open") {
+ const allowed = isAllowedBlueBubblesSender({
+ allowFrom: effectiveAllowFrom,
+ sender: reaction.senderId,
+ chatId: reaction.chatId ?? undefined,
+ chatGuid: reaction.chatGuid ?? undefined,
+ chatIdentifier: reaction.chatIdentifier ?? undefined,
+ });
+ if (!allowed) {
+ return;
+ }
+ }
+ }
+
+ const chatId = reaction.chatId ?? undefined;
+ const chatGuid = reaction.chatGuid ?? undefined;
+ const chatIdentifier = reaction.chatIdentifier ?? undefined;
+ const peerId = reaction.isGroup
+ ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
+ : reaction.senderId;
+
+ const route = core.channel.routing.resolveAgentRoute({
+ cfg: config,
+ channel: "bluebubbles",
+ accountId: account.accountId,
+ peer: {
+ kind: reaction.isGroup ? "group" : "direct",
+ id: peerId,
+ },
+ });
+
+ const senderLabel = reaction.senderName || reaction.senderId;
+ const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
+ // Use short ID for token savings
+ const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
+ // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
+ const text =
+ reaction.action === "removed"
+ ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
+ : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
+ core.system.enqueueSystemEvent(text, {
+ sessionKey: route.sessionKey,
+ contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
+ });
+ logVerbose(core, runtime, `reaction event enqueued: ${text}`);
+}
diff --git a/extensions/bluebubbles/src/monitor-reply-cache.ts b/extensions/bluebubbles/src/monitor-reply-cache.ts
new file mode 100644
index 00000000000..f2fe8774be8
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-reply-cache.ts
@@ -0,0 +1,185 @@
+const REPLY_CACHE_MAX = 2000;
+const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
+
+type BlueBubblesReplyCacheEntry = {
+ accountId: string;
+ messageId: string;
+ shortId: string;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatId?: number;
+ senderLabel?: string;
+ body?: string;
+ timestamp: number;
+};
+
+// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
+const blueBubblesReplyCacheByMessageId = new Map();
+
+// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
+const blueBubblesShortIdToUuid = new Map();
+const blueBubblesUuidToShortId = new Map();
+let blueBubblesShortIdCounter = 0;
+
+function trimOrUndefined(value?: string | null): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function generateShortId(): string {
+ blueBubblesShortIdCounter += 1;
+ return String(blueBubblesShortIdCounter);
+}
+
+export function rememberBlueBubblesReplyCache(
+ entry: Omit,
+): BlueBubblesReplyCacheEntry {
+ const messageId = entry.messageId.trim();
+ if (!messageId) {
+ return { ...entry, shortId: "" };
+ }
+
+ // Check if we already have a short ID for this GUID
+ let shortId = blueBubblesUuidToShortId.get(messageId);
+ if (!shortId) {
+ shortId = generateShortId();
+ blueBubblesShortIdToUuid.set(shortId, messageId);
+ blueBubblesUuidToShortId.set(messageId, shortId);
+ }
+
+ const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
+
+ // Refresh insertion order.
+ blueBubblesReplyCacheByMessageId.delete(messageId);
+ blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
+
+ // Opportunistic prune.
+ const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
+ for (const [key, value] of blueBubblesReplyCacheByMessageId) {
+ if (value.timestamp < cutoff) {
+ blueBubblesReplyCacheByMessageId.delete(key);
+ // Clean up short ID mappings for expired entries
+ if (value.shortId) {
+ blueBubblesShortIdToUuid.delete(value.shortId);
+ blueBubblesUuidToShortId.delete(key);
+ }
+ continue;
+ }
+ break;
+ }
+ while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
+ const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
+ if (!oldest) {
+ break;
+ }
+ const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
+ blueBubblesReplyCacheByMessageId.delete(oldest);
+ // Clean up short ID mappings for evicted entries
+ if (oldEntry?.shortId) {
+ blueBubblesShortIdToUuid.delete(oldEntry.shortId);
+ blueBubblesUuidToShortId.delete(oldest);
+ }
+ }
+
+ return fullEntry;
+}
+
+/**
+ * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
+ * Returns the input unchanged if it's already a GUID or not found in the mapping.
+ */
+export function resolveBlueBubblesMessageId(
+ shortOrUuid: string,
+ opts?: { requireKnownShortId?: boolean },
+): string {
+ const trimmed = shortOrUuid.trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+
+ // If it looks like a short ID (numeric), try to resolve it
+ if (/^\d+$/.test(trimmed)) {
+ const uuid = blueBubblesShortIdToUuid.get(trimmed);
+ if (uuid) {
+ return uuid;
+ }
+ if (opts?.requireKnownShortId) {
+ throw new Error(
+ `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
+ );
+ }
+ }
+
+ // Return as-is (either already a UUID or not found)
+ return trimmed;
+}
+
+/**
+ * Resets the short ID state. Only use in tests.
+ * @internal
+ */
+export function _resetBlueBubblesShortIdState(): void {
+ blueBubblesShortIdToUuid.clear();
+ blueBubblesUuidToShortId.clear();
+ blueBubblesReplyCacheByMessageId.clear();
+ blueBubblesShortIdCounter = 0;
+}
+
+/**
+ * Gets the short ID for a message GUID, if one exists.
+ */
+export function getShortIdForUuid(uuid: string): string | undefined {
+ return blueBubblesUuidToShortId.get(uuid.trim());
+}
+
+export function resolveReplyContextFromCache(params: {
+ accountId: string;
+ replyToId: string;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatId?: number;
+}): BlueBubblesReplyCacheEntry | null {
+ const replyToId = params.replyToId.trim();
+ if (!replyToId) {
+ return null;
+ }
+
+ const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
+ if (!cached) {
+ return null;
+ }
+ if (cached.accountId !== params.accountId) {
+ return null;
+ }
+
+ const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
+ if (cached.timestamp < cutoff) {
+ blueBubblesReplyCacheByMessageId.delete(replyToId);
+ return null;
+ }
+
+ const chatGuid = trimOrUndefined(params.chatGuid);
+ const chatIdentifier = trimOrUndefined(params.chatIdentifier);
+ const cachedChatGuid = trimOrUndefined(cached.chatGuid);
+ const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
+ const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
+ const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
+
+ // Avoid cross-chat collisions if we have identifiers.
+ if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
+ return null;
+ }
+ if (
+ !chatGuid &&
+ chatIdentifier &&
+ cachedChatIdentifier &&
+ chatIdentifier !== cachedChatIdentifier
+ ) {
+ return null;
+ }
+ if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
+ return null;
+ }
+
+ return cached;
+}
diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts
new file mode 100644
index 00000000000..88e84039417
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-shared.ts
@@ -0,0 +1,51 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import type { ResolvedBlueBubblesAccount } from "./accounts.js";
+import { getBlueBubblesRuntime } from "./runtime.js";
+import type { BlueBubblesAccountConfig } from "./types.js";
+
+export type BlueBubblesRuntimeEnv = {
+ log?: (message: string) => void;
+ error?: (message: string) => void;
+};
+
+export type BlueBubblesMonitorOptions = {
+ account: ResolvedBlueBubblesAccount;
+ config: OpenClawConfig;
+ runtime: BlueBubblesRuntimeEnv;
+ abortSignal: AbortSignal;
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
+ webhookPath?: string;
+};
+
+export type BlueBubblesCoreRuntime = ReturnType;
+
+export type WebhookTarget = {
+ account: ResolvedBlueBubblesAccount;
+ config: OpenClawConfig;
+ runtime: BlueBubblesRuntimeEnv;
+ core: BlueBubblesCoreRuntime;
+ path: string;
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
+};
+
+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) {
+ return normalizeWebhookPath(raw);
+ }
+ return DEFAULT_WEBHOOK_PATH;
+}
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index a1b3c843be6..3f08a78c9a2 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -1,6 +1,6 @@
+import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
-import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -52,9 +52,22 @@ const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((r) => r.test(text)),
);
+const mockMatchesMentionWithExplicit = vi.fn(
+ (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
+ if (params.explicitWasMentioned) {
+ return true;
+ }
+ return params.mentionRegexes.some((regex) => regex.test(params.text));
+ },
+);
const mockResolveRequireMention = vi.fn(() => false);
const mockResolveGroupPolicy = vi.fn(() => "open");
-const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
+type DispatchReplyParams = Parameters<
+ PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
+>[0];
+const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
+ async (_params: DispatchReplyParams): Promise => undefined,
+);
const mockHasControlCommand = vi.fn(() => false);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
@@ -67,7 +80,12 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
template: "channel+name+time",
}));
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
+const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
+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");
function createMockRuntime(): PluginRuntime {
return {
@@ -80,6 +98,9 @@ function createMockRuntime(): PluginRuntime {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
+ formatNativeDependencyHint: vi.fn(
+ () => "",
+ ) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
@@ -90,6 +111,9 @@ function createMockRuntime(): PluginRuntime {
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
+ tts: {
+ textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
+ },
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
@@ -101,6 +125,14 @@ function createMockRuntime(): PluginRuntime {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
+ chunkByNewline:
+ mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
+ chunkMarkdownTextWithMode:
+ mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
+ chunkTextWithMode:
+ mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
+ resolveChunkMode:
+ mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
@@ -124,12 +156,13 @@ function createMockRuntime(): PluginRuntime {
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
- finalizeInboundContext:
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
+ finalizeInboundContext: vi.fn(
+ (ctx: Record) => ctx,
+ ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope:
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
+ mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions:
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
@@ -168,6 +201,8 @@ function createMockRuntime(): PluginRuntime {
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns:
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
+ matchesMentionWithExplicit:
+ mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
@@ -204,6 +239,8 @@ function createMockRuntime(): PluginRuntime {
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
+ activity: {} as PluginRuntime["channel"]["activity"],
+ line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
@@ -254,6 +291,9 @@ function createMockRequest(
body: unknown,
headers: Record = {},
): IncomingMessage {
+ if (headers.host === undefined) {
+ headers.host = "localhost";
+ }
const parsedUrl = new URL(url, "http://localhost");
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
const hasAuthHeader =
@@ -300,6 +340,14 @@ const flushAsync = async () => {
}
};
+function getFirstDispatchCall(): DispatchReplyParams {
+ const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
+ if (!callArgs) {
+ throw new Error("expected dispatch call arguments");
+ }
+ return callArgs;
+}
+
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
@@ -404,7 +452,7 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(400);
});
- it("returns 400 when request body times out (Slow-Loris protection)", async () => {
+ it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount();
@@ -439,7 +487,7 @@ describe("BlueBubbles webhook monitor", () => {
const handled = await handledPromise;
expect(handled).toBe(true);
- expect(res.statusCode).toBe(400);
+ expect(res.statusCode).toBe(408);
expect(req.destroy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -557,6 +605,114 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(401);
});
+ it("rejects ambiguous routing when multiple targets match the same password", async () => {
+ const accountA = createMockAccount({ password: "secret-token" });
+ const accountB = createMockAccount({ password: "secret-token" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const sinkA = vi.fn();
+ const sinkB = vi.fn();
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ },
+ });
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
+ remoteAddress: "192.168.1.100",
+ };
+
+ const unregisterA = registerBlueBubblesWebhookTarget({
+ account: accountA,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ statusSink: sinkA,
+ });
+ const unregisterB = registerBlueBubblesWebhookTarget({
+ account: accountB,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ statusSink: sinkB,
+ });
+ unregister = () => {
+ unregisterA();
+ unregisterB();
+ };
+
+ const res = createMockResponse();
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
+
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(401);
+ expect(sinkA).not.toHaveBeenCalled();
+ expect(sinkB).not.toHaveBeenCalled();
+ });
+
+ it("does not route to passwordless targets when a password-authenticated target matches", async () => {
+ const accountStrict = createMockAccount({ password: "secret-token" });
+ const accountFallback = createMockAccount({ password: undefined });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const sinkStrict = vi.fn();
+ const sinkFallback = vi.fn();
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ },
+ });
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
+ remoteAddress: "192.168.1.100",
+ };
+
+ const unregisterStrict = registerBlueBubblesWebhookTarget({
+ account: accountStrict,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ statusSink: sinkStrict,
+ });
+ const unregisterFallback = registerBlueBubblesWebhookTarget({
+ account: accountFallback,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ statusSink: sinkFallback,
+ });
+ unregister = () => {
+ unregisterStrict();
+ unregisterFallback();
+ };
+
+ const res = createMockResponse();
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
+
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(200);
+ expect(sinkStrict).toHaveBeenCalledTimes(1);
+ expect(sinkFallback).not.toHaveBeenCalled();
+ });
+
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
@@ -594,6 +750,79 @@ describe("BlueBubbles webhook monitor", () => {
}
});
+ it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
+ const account = createMockAccount({ password: undefined });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const req = createMockRequest(
+ "POST",
+ "/bluebubbles-webhook",
+ {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ },
+ },
+ { "x-forwarded-for": "203.0.113.10", host: "localhost" },
+ );
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
+ remoteAddress: "127.0.0.1",
+ };
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const res = createMockResponse();
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(401);
+ });
+
+ it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
+ const account = createMockAccount({ password: undefined });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ },
+ });
+ (req as unknown as { socket: { remoteAddress: string } }).socket = {
+ remoteAddress: "127.0.0.1",
+ };
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const res = createMockResponse();
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(200);
+ });
+
it("ignores unregistered webhook paths", async () => {
const req = createMockRequest("POST", "/unregistered-path", {});
const res = createMockResponse();
@@ -1133,7 +1362,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.WasMentioned).toBe(true);
});
@@ -1255,12 +1484,151 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.GroupSubject).toBe("Family");
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
});
});
+ describe("group sender identity in envelope", () => {
+ it("includes sender in envelope body and group label as from for group messages", async () => {
+ const account = createMockAccount({ groupPolicy: "open" });
+ 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: "hello everyone",
+ handle: { address: "+15551234567" },
+ senderName: "Alice",
+ isGroup: true,
+ isFromMe: false,
+ guid: "msg-1",
+ chatGuid: "iMessage;+;chat123456",
+ chatName: "Family Chat",
+ date: Date.now(),
+ },
+ };
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
+ const res = createMockResponse();
+
+ await handleBlueBubblesWebhookRequest(req, res);
+ await flushAsync();
+
+ // formatInboundEnvelope should be called with group label + id as from, and sender info
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: "Family Chat id:iMessage;+;chat123456",
+ chatType: "group",
+ sender: { name: "Alice", id: "+15551234567" },
+ }),
+ );
+ // ConversationLabel should be the group label + id, not the sender
+ const callArgs = getFirstDispatchCall();
+ expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
+ expect(callArgs.ctx.SenderName).toBe("Alice");
+ // BodyForAgent should be raw text, not the envelope-formatted body
+ expect(callArgs.ctx.BodyForAgent).toBe("hello everyone");
+ });
+
+ it("falls back to group:peerId when chatName is missing", async () => {
+ const account = createMockAccount({ groupPolicy: "open" });
+ 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: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: true,
+ isFromMe: false,
+ guid: "msg-1",
+ chatGuid: "iMessage;+;chat123456",
+ date: Date.now(),
+ },
+ };
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
+ const res = createMockResponse();
+
+ await handleBlueBubblesWebhookRequest(req, res);
+ await flushAsync();
+
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: expect.stringMatching(/^Group id:/),
+ chatType: "group",
+ sender: { name: undefined, id: "+15551234567" },
+ }),
+ );
+ });
+
+ it("uses sender as from label for DM messages", async () => {
+ const account = createMockAccount();
+ 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: "hello",
+ handle: { address: "+15551234567" },
+ senderName: "Alice",
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ date: Date.now(),
+ },
+ };
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
+ const res = createMockResponse();
+
+ await handleBlueBubblesWebhookRequest(req, res);
+ await flushAsync();
+
+ expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: "Alice id:+15551234567",
+ chatType: "direct",
+ sender: { name: "Alice", id: "+15551234567" },
+ }),
+ );
+ const callArgs = getFirstDispatchCall();
+ expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
+ });
+ });
+
describe("inbound debouncing", () => {
it("coalesces text-only then attachment webhook events by messageId", async () => {
vi.useFakeTimers();
@@ -1391,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
expect(callArgs.ctx.Body).toContain("hello");
} finally {
@@ -1440,7 +1808,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
// ReplyToId is the full UUID since it wasn't previously cached
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
@@ -1488,7 +1856,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
@@ -1554,7 +1922,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
// ReplyToId uses short ID "1" (first cached message) for token savings
expect(callArgs.ctx.ReplyToId).toBe("1");
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
@@ -1599,7 +1967,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
});
});
@@ -1639,7 +2007,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
@@ -1679,7 +2047,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
@@ -2299,7 +2667,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
- const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
+ const callArgs = getFirstDispatchCall();
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index bc325b48dab..9b5bd24091a 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -1,281 +1,31 @@
+import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
- createReplyPrefixOptions,
- logAckFailure,
- logInboundDrop,
- logTypingFailure,
- resolveAckReaction,
- resolveControlCommandGate,
+ registerWebhookTarget,
+ rejectNonPostWebhookRequest,
+ resolveWebhookTargets,
} from "openclaw/plugin-sdk";
-import type { ResolvedBlueBubblesAccount } from "./accounts.js";
-import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
-import { downloadBlueBubblesAttachment } from "./attachments.js";
-import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
-import { sendBlueBubblesMedia } from "./media-send.js";
-import { fetchBlueBubblesServerInfo } from "./probe.js";
-import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
-import { getBlueBubblesRuntime } from "./runtime.js";
-import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import {
- formatBlueBubblesChatTarget,
- isAllowedBlueBubblesSender,
- normalizeBlueBubblesHandle,
-} from "./targets.js";
-
-export type BlueBubblesRuntimeEnv = {
- log?: (message: string) => void;
- error?: (message: string) => void;
-};
-
-export type BlueBubblesMonitorOptions = {
- account: ResolvedBlueBubblesAccount;
- config: OpenClawConfig;
- runtime: BlueBubblesRuntimeEnv;
- abortSignal: AbortSignal;
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
- webhookPath?: string;
-};
-
-const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
-const DEFAULT_TEXT_LIMIT = 4000;
-const invalidAckReactions = new Set();
-
-const REPLY_CACHE_MAX = 2000;
-const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
-
-type BlueBubblesReplyCacheEntry = {
- accountId: string;
- messageId: string;
- shortId: string;
- chatGuid?: string;
- chatIdentifier?: string;
- chatId?: number;
- senderLabel?: string;
- body?: string;
- timestamp: number;
-};
-
-// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
-const blueBubblesReplyCacheByMessageId = new Map();
-
-// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
-const blueBubblesShortIdToUuid = new Map();
-const blueBubblesUuidToShortId = new Map();
-let blueBubblesShortIdCounter = 0;
-
-function trimOrUndefined(value?: string | null): string | undefined {
- const trimmed = value?.trim();
- return trimmed ? trimmed : undefined;
-}
-
-function generateShortId(): string {
- blueBubblesShortIdCounter += 1;
- return String(blueBubblesShortIdCounter);
-}
-
-function rememberBlueBubblesReplyCache(
- entry: Omit,
-): BlueBubblesReplyCacheEntry {
- const messageId = entry.messageId.trim();
- if (!messageId) {
- return { ...entry, shortId: "" };
- }
-
- // Check if we already have a short ID for this GUID
- let shortId = blueBubblesUuidToShortId.get(messageId);
- if (!shortId) {
- shortId = generateShortId();
- blueBubblesShortIdToUuid.set(shortId, messageId);
- blueBubblesUuidToShortId.set(messageId, shortId);
- }
-
- const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
-
- // Refresh insertion order.
- blueBubblesReplyCacheByMessageId.delete(messageId);
- blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
-
- // Opportunistic prune.
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
- for (const [key, value] of blueBubblesReplyCacheByMessageId) {
- if (value.timestamp < cutoff) {
- blueBubblesReplyCacheByMessageId.delete(key);
- // Clean up short ID mappings for expired entries
- if (value.shortId) {
- blueBubblesShortIdToUuid.delete(value.shortId);
- blueBubblesUuidToShortId.delete(key);
- }
- continue;
- }
- break;
- }
- while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
- const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
- if (!oldest) {
- break;
- }
- const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
- blueBubblesReplyCacheByMessageId.delete(oldest);
- // Clean up short ID mappings for evicted entries
- if (oldEntry?.shortId) {
- blueBubblesShortIdToUuid.delete(oldEntry.shortId);
- blueBubblesUuidToShortId.delete(oldest);
- }
- }
-
- return fullEntry;
-}
-
-/**
- * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
- * Returns the input unchanged if it's already a GUID or not found in the mapping.
- */
-export function resolveBlueBubblesMessageId(
- shortOrUuid: string,
- opts?: { requireKnownShortId?: boolean },
-): string {
- const trimmed = shortOrUuid.trim();
- if (!trimmed) {
- return trimmed;
- }
-
- // If it looks like a short ID (numeric), try to resolve it
- if (/^\d+$/.test(trimmed)) {
- const uuid = blueBubblesShortIdToUuid.get(trimmed);
- if (uuid) {
- return uuid;
- }
- if (opts?.requireKnownShortId) {
- throw new Error(
- `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
- );
- }
- }
-
- // Return as-is (either already a UUID or not found)
- return trimmed;
-}
-
-/**
- * Resets the short ID state. Only use in tests.
- * @internal
- */
-export function _resetBlueBubblesShortIdState(): void {
- blueBubblesShortIdToUuid.clear();
- blueBubblesUuidToShortId.clear();
- blueBubblesReplyCacheByMessageId.clear();
- blueBubblesShortIdCounter = 0;
-}
-
-/**
- * Gets the short ID for a message GUID, if one exists.
- */
-function getShortIdForUuid(uuid: string): string | undefined {
- return blueBubblesUuidToShortId.get(uuid.trim());
-}
-
-function resolveReplyContextFromCache(params: {
- accountId: string;
- replyToId: string;
- chatGuid?: string;
- chatIdentifier?: string;
- chatId?: number;
-}): BlueBubblesReplyCacheEntry | null {
- const replyToId = params.replyToId.trim();
- if (!replyToId) {
- return null;
- }
-
- const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
- if (!cached) {
- return null;
- }
- if (cached.accountId !== params.accountId) {
- return null;
- }
-
- const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
- if (cached.timestamp < cutoff) {
- blueBubblesReplyCacheByMessageId.delete(replyToId);
- return null;
- }
-
- const chatGuid = trimOrUndefined(params.chatGuid);
- const chatIdentifier = trimOrUndefined(params.chatIdentifier);
- const cachedChatGuid = trimOrUndefined(cached.chatGuid);
- const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
- const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
- const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
-
- // Avoid cross-chat collisions if we have identifiers.
- if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
- return null;
- }
- if (
- !chatGuid &&
- chatIdentifier &&
- cachedChatIdentifier &&
- chatIdentifier !== cachedChatIdentifier
- ) {
- return null;
- }
- if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
- return null;
- }
-
- return cached;
-}
-
-type BlueBubblesCoreRuntime = ReturnType;
-
-function logVerbose(
- core: BlueBubblesCoreRuntime,
- runtime: BlueBubblesRuntimeEnv,
- message: string,
-): void {
- if (core.logging.shouldLogVerbose()) {
- runtime.log?.(`[bluebubbles] ${message}`);
- }
-}
-
-function logGroupAllowlistHint(params: {
- runtime: BlueBubblesRuntimeEnv;
- reason: string;
- entry: string | null;
- chatName?: string;
- accountId?: string;
-}): void {
- const log = params.runtime.log ?? console.log;
- const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
- const accountHint = params.accountId
- ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
- : "";
- if (params.entry) {
- log(
- `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
- `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
- );
- log(
- `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
- );
- return;
- }
- log(
- `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
- `channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
- `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
- );
-}
-
-type WebhookTarget = {
- account: ResolvedBlueBubblesAccount;
- config: OpenClawConfig;
- runtime: BlueBubblesRuntimeEnv;
- core: BlueBubblesCoreRuntime;
- path: string;
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
-};
+ normalizeWebhookMessage,
+ normalizeWebhookReaction,
+ type NormalizedWebhookMessage,
+} from "./monitor-normalize.js";
+import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
+import {
+ _resetBlueBubblesShortIdState,
+ resolveBlueBubblesMessageId,
+} from "./monitor-reply-cache.js";
+import {
+ DEFAULT_WEBHOOK_PATH,
+ normalizeWebhookPath,
+ resolveWebhookPathFromConfig,
+ type BlueBubblesCoreRuntime,
+ type BlueBubblesMonitorOptions,
+ type WebhookTarget,
+} from "./monitor-shared.js";
+import { fetchBlueBubblesServerInfo } from "./probe.js";
+import { getBlueBubblesRuntime } from "./runtime.js";
/**
* Entry type for debouncing inbound messages.
@@ -480,33 +230,12 @@ function removeDebouncer(target: WebhookTarget): void {
targetDebouncers.delete(target);
}
-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 registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
- const key = normalizeWebhookPath(target.path);
- const normalizedTarget = { ...target, path: key };
- const existing = webhookTargets.get(key) ?? [];
- const next = [...existing, normalizedTarget];
- webhookTargets.set(key, next);
+ const registered = registerWebhookTarget(webhookTargets, target);
return () => {
- const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
- if (updated.length > 0) {
- webhookTargets.set(key, updated);
- } else {
- webhookTargets.delete(key);
- }
+ registered.unregister();
// Clean up debouncer when target is unregistered
- removeDebouncer(normalizedTarget);
+ removeDebouncer(registered.target);
};
}
@@ -576,522 +305,6 @@ function asRecord(value: unknown): Record | null {
: null;
}
-function readString(record: Record | null, key: string): string | undefined {
- if (!record) {
- return undefined;
- }
- const value = record[key];
- return typeof value === "string" ? value : undefined;
-}
-
-function readNumber(record: Record | null, key: string): number | undefined {
- if (!record) {
- return undefined;
- }
- const value = record[key];
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
-}
-
-function readBoolean(record: Record | null, key: string): boolean | undefined {
- if (!record) {
- return undefined;
- }
- const value = record[key];
- return typeof value === "boolean" ? value : undefined;
-}
-
-function extractAttachments(message: Record): BlueBubblesAttachment[] {
- const raw = message["attachments"];
- if (!Array.isArray(raw)) {
- return [];
- }
- const out: BlueBubblesAttachment[] = [];
- for (const entry of raw) {
- const record = asRecord(entry);
- if (!record) {
- continue;
- }
- out.push({
- guid: readString(record, "guid"),
- uti: readString(record, "uti"),
- mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
- transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
- totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
- height: readNumberLike(record, "height"),
- width: readNumberLike(record, "width"),
- originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
- });
- }
- return out;
-}
-
-function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
- if (attachments.length === 0) {
- return "";
- }
- const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
- const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
- const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
- const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
- const tag = allImages
- ? ""
- : allVideos
- ? ""
- : allAudio
- ? ""
- : "";
- const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
- const suffix = attachments.length === 1 ? label : `${label}s`;
- return `${tag} (${attachments.length} ${suffix})`;
-}
-
-function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
- const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
- if (attachmentPlaceholder) {
- return attachmentPlaceholder;
- }
- if (message.balloonBundleId) {
- return "";
- }
- return "";
-}
-
-// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
-function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
- // Prefer short ID
- const rawId = message.replyToShortId || message.replyToId;
- if (!rawId) {
- return null;
- }
- return `[[reply_to:${rawId}]]`;
-}
-
-function readNumberLike(record: Record | null, key: string): number | undefined {
- if (!record) {
- return undefined;
- }
- const value = record[key];
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string") {
- const parsed = Number.parseFloat(value);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
- }
- return undefined;
-}
-
-function extractReplyMetadata(message: Record): {
- replyToId?: string;
- replyToBody?: string;
- replyToSender?: string;
-} {
- const replyRaw =
- message["replyTo"] ??
- message["reply_to"] ??
- message["replyToMessage"] ??
- message["reply_to_message"] ??
- message["repliedMessage"] ??
- message["quotedMessage"] ??
- message["associatedMessage"] ??
- message["reply"];
- const replyRecord = asRecord(replyRaw);
- const replyHandle =
- asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
- const replySenderRaw =
- readString(replyHandle, "address") ??
- readString(replyHandle, "handle") ??
- readString(replyHandle, "id") ??
- readString(replyRecord, "senderId") ??
- readString(replyRecord, "sender") ??
- readString(replyRecord, "from");
- const normalizedSender = replySenderRaw
- ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
- : undefined;
-
- const replyToBody =
- readString(replyRecord, "text") ??
- readString(replyRecord, "body") ??
- readString(replyRecord, "message") ??
- readString(replyRecord, "subject") ??
- undefined;
-
- const directReplyId =
- readString(message, "replyToMessageGuid") ??
- readString(message, "replyToGuid") ??
- readString(message, "replyGuid") ??
- readString(message, "selectedMessageGuid") ??
- readString(message, "selectedMessageId") ??
- readString(message, "replyToMessageId") ??
- readString(message, "replyId") ??
- readString(replyRecord, "guid") ??
- readString(replyRecord, "id") ??
- readString(replyRecord, "messageId");
-
- const associatedType =
- readNumberLike(message, "associatedMessageType") ??
- readNumberLike(message, "associated_message_type");
- const associatedGuid =
- readString(message, "associatedMessageGuid") ??
- readString(message, "associated_message_guid") ??
- readString(message, "associatedMessageId");
- const isReactionAssociation =
- typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
-
- const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
- const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
- const messageGuid = readString(message, "guid");
- const fallbackReplyId =
- !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
- ? threadOriginatorGuid
- : undefined;
-
- return {
- replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
- replyToBody: replyToBody?.trim() || undefined,
- replyToSender: normalizedSender || undefined,
- };
-}
-
-function readFirstChatRecord(message: Record): Record | null {
- const chats = message["chats"];
- if (!Array.isArray(chats) || chats.length === 0) {
- return null;
- }
- const first = chats[0];
- return asRecord(first);
-}
-
-function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
- if (typeof entry === "string" || typeof entry === "number") {
- const raw = String(entry).trim();
- if (!raw) {
- return null;
- }
- const normalized = normalizeBlueBubblesHandle(raw) || raw;
- return normalized ? { id: normalized } : null;
- }
- const record = asRecord(entry);
- if (!record) {
- return null;
- }
- const nestedHandle =
- asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
- const idRaw =
- readString(record, "address") ??
- readString(record, "handle") ??
- readString(record, "id") ??
- readString(record, "phoneNumber") ??
- readString(record, "phone_number") ??
- readString(record, "email") ??
- readString(nestedHandle, "address") ??
- readString(nestedHandle, "handle") ??
- readString(nestedHandle, "id");
- const nameRaw =
- readString(record, "displayName") ??
- readString(record, "name") ??
- readString(record, "title") ??
- readString(nestedHandle, "displayName") ??
- readString(nestedHandle, "name");
- const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
- if (!normalizedId) {
- return null;
- }
- const name = nameRaw?.trim() || undefined;
- return { id: normalizedId, name };
-}
-
-function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
- if (!Array.isArray(raw) || raw.length === 0) {
- return [];
- }
- const seen = new Set();
- const output: BlueBubblesParticipant[] = [];
- for (const entry of raw) {
- const normalized = normalizeParticipantEntry(entry);
- if (!normalized?.id) {
- continue;
- }
- const key = normalized.id.toLowerCase();
- if (seen.has(key)) {
- continue;
- }
- seen.add(key);
- output.push(normalized);
- }
- return output;
-}
-
-function formatGroupMembers(params: {
- participants?: BlueBubblesParticipant[];
- fallback?: BlueBubblesParticipant;
-}): string | undefined {
- const seen = new Set();
- const ordered: BlueBubblesParticipant[] = [];
- for (const entry of params.participants ?? []) {
- if (!entry?.id) {
- continue;
- }
- const key = entry.id.toLowerCase();
- if (seen.has(key)) {
- continue;
- }
- seen.add(key);
- ordered.push(entry);
- }
- if (ordered.length === 0 && params.fallback?.id) {
- ordered.push(params.fallback);
- }
- if (ordered.length === 0) {
- return undefined;
- }
- return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
-}
-
-function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
- const guid = chatGuid?.trim();
- if (!guid) {
- return undefined;
- }
- const parts = guid.split(";");
- if (parts.length >= 3) {
- if (parts[1] === "+") {
- return true;
- }
- if (parts[1] === "-") {
- return false;
- }
- }
- if (guid.includes(";+;")) {
- return true;
- }
- if (guid.includes(";-;")) {
- return false;
- }
- return undefined;
-}
-
-function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
- const guid = chatGuid?.trim();
- if (!guid) {
- return undefined;
- }
- const parts = guid.split(";");
- if (parts.length < 3) {
- return undefined;
- }
- const identifier = parts[2]?.trim();
- return identifier || undefined;
-}
-
-function formatGroupAllowlistEntry(params: {
- chatGuid?: string;
- chatId?: number;
- chatIdentifier?: string;
-}): string | null {
- const guid = params.chatGuid?.trim();
- if (guid) {
- return `chat_guid:${guid}`;
- }
- const chatId = params.chatId;
- if (typeof chatId === "number" && Number.isFinite(chatId)) {
- return `chat_id:${chatId}`;
- }
- const identifier = params.chatIdentifier?.trim();
- if (identifier) {
- return `chat_identifier:${identifier}`;
- }
- return null;
-}
-
-type BlueBubblesParticipant = {
- id: string;
- name?: string;
-};
-
-type NormalizedWebhookMessage = {
- text: string;
- senderId: string;
- senderName?: string;
- messageId?: string;
- timestamp?: number;
- isGroup: boolean;
- chatId?: number;
- chatGuid?: string;
- chatIdentifier?: string;
- chatName?: string;
- fromMe?: boolean;
- attachments?: BlueBubblesAttachment[];
- balloonBundleId?: string;
- associatedMessageGuid?: string;
- associatedMessageType?: number;
- associatedMessageEmoji?: string;
- isTapback?: boolean;
- participants?: BlueBubblesParticipant[];
- replyToId?: string;
- replyToBody?: string;
- replyToSender?: string;
-};
-
-type NormalizedWebhookReaction = {
- action: "added" | "removed";
- emoji: string;
- senderId: string;
- senderName?: string;
- messageId: string;
- timestamp?: number;
- isGroup: boolean;
- chatId?: number;
- chatGuid?: string;
- chatIdentifier?: string;
- chatName?: string;
- fromMe?: boolean;
-};
-
-const REACTION_TYPE_MAP = new Map([
- [2000, { emoji: "❤️", action: "added" }],
- [2001, { emoji: "👍", action: "added" }],
- [2002, { emoji: "👎", action: "added" }],
- [2003, { emoji: "😂", action: "added" }],
- [2004, { emoji: "‼️", action: "added" }],
- [2005, { emoji: "❓", action: "added" }],
- [3000, { emoji: "❤️", action: "removed" }],
- [3001, { emoji: "👍", action: "removed" }],
- [3002, { emoji: "👎", action: "removed" }],
- [3003, { emoji: "😂", action: "removed" }],
- [3004, { emoji: "‼️", action: "removed" }],
- [3005, { emoji: "❓", action: "removed" }],
-]);
-
-// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
-const TAPBACK_TEXT_MAP = new Map([
- ["loved", { emoji: "❤️", action: "added" }],
- ["liked", { emoji: "👍", action: "added" }],
- ["disliked", { emoji: "👎", action: "added" }],
- ["laughed at", { emoji: "😂", action: "added" }],
- ["emphasized", { emoji: "‼️", action: "added" }],
- ["questioned", { emoji: "❓", action: "added" }],
- // Removal patterns (e.g., "Removed a heart from")
- ["removed a heart from", { emoji: "❤️", action: "removed" }],
- ["removed a like from", { emoji: "👍", action: "removed" }],
- ["removed a dislike from", { emoji: "👎", action: "removed" }],
- ["removed a laugh from", { emoji: "😂", action: "removed" }],
- ["removed an emphasis from", { emoji: "‼️", action: "removed" }],
- ["removed a question from", { emoji: "❓", action: "removed" }],
-]);
-
-const TAPBACK_EMOJI_REGEX =
- /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
-
-function extractFirstEmoji(text: string): string | null {
- const match = text.match(TAPBACK_EMOJI_REGEX);
- return match ? match[0] : null;
-}
-
-function extractQuotedTapbackText(text: string): string | null {
- const match = text.match(/[“"]([^”"]+)[”"]/s);
- return match ? match[1] : null;
-}
-
-function isTapbackAssociatedType(type: number | undefined): boolean {
- return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
-}
-
-function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
- if (typeof type !== "number" || !Number.isFinite(type)) {
- return undefined;
- }
- if (type >= 3000 && type < 4000) {
- return "removed";
- }
- if (type >= 2000 && type < 3000) {
- return "added";
- }
- return undefined;
-}
-
-function resolveTapbackContext(message: NormalizedWebhookMessage): {
- emojiHint?: string;
- actionHint?: "added" | "removed";
- replyToId?: string;
-} | null {
- const associatedType = message.associatedMessageType;
- const hasTapbackType = isTapbackAssociatedType(associatedType);
- const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
- if (!hasTapbackType && !hasTapbackMarker) {
- return null;
- }
- const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
- const actionHint = resolveTapbackActionHint(associatedType);
- const emojiHint =
- message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
- return { emojiHint, actionHint, replyToId };
-}
-
-// Detects tapback text patterns like 'Loved "message"' and converts to structured format
-function parseTapbackText(params: {
- text: string;
- emojiHint?: string;
- actionHint?: "added" | "removed";
- requireQuoted?: boolean;
-}): {
- emoji: string;
- action: "added" | "removed";
- quotedText: string;
-} | null {
- const trimmed = params.text.trim();
- const lower = trimmed.toLowerCase();
- if (!trimmed) {
- return null;
- }
-
- for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
- if (lower.startsWith(pattern)) {
- // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
- const afterPattern = trimmed.slice(pattern.length).trim();
- if (params.requireQuoted) {
- const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
- if (!strictMatch) {
- return null;
- }
- return { emoji, action, quotedText: strictMatch[1] };
- }
- const quotedText =
- extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
- return { emoji, action, quotedText };
- }
- }
-
- if (lower.startsWith("reacted")) {
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
- if (!emoji) {
- return null;
- }
- const quotedText = extractQuotedTapbackText(trimmed);
- if (params.requireQuoted && !quotedText) {
- return null;
- }
- const fallback = trimmed.slice("reacted".length).trim();
- return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
- }
-
- if (lower.startsWith("removed")) {
- const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
- if (!emoji) {
- return null;
- }
- const quotedText = extractQuotedTapbackText(trimmed);
- if (params.requireQuoted && !quotedText) {
- return null;
- }
- const fallback = trimmed.slice("removed".length).trim();
- return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
- }
- return null;
-}
-
function maskSecret(value: string): string {
if (value.length <= 6) {
return "***";
@@ -1099,369 +312,97 @@ function maskSecret(value: string): string {
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
-function resolveBlueBubblesAckReaction(params: {
- cfg: OpenClawConfig;
- agentId: string;
- core: BlueBubblesCoreRuntime;
- runtime: BlueBubblesRuntimeEnv;
-}): string | null {
- const raw = resolveAckReaction(params.cfg, params.agentId).trim();
- if (!raw) {
- return null;
+function normalizeAuthToken(raw: string): string {
+ const value = raw.trim();
+ if (!value) {
+ return "";
}
- try {
- normalizeBlueBubblesReactionInput(raw);
- return raw;
- } catch {
- const key = raw.toLowerCase();
- if (!invalidAckReactions.has(key)) {
- invalidAckReactions.add(key);
- logVerbose(
- params.core,
- params.runtime,
- `ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
- );
+ if (value.toLowerCase().startsWith("bearer ")) {
+ return value.slice("bearer ".length).trim();
+ }
+ return value;
+}
+
+function safeEqualSecret(aRaw: string, bRaw: string): boolean {
+ const a = normalizeAuthToken(aRaw);
+ const b = normalizeAuthToken(bRaw);
+ if (!a || !b) {
+ return false;
+ }
+ const bufA = Buffer.from(a, "utf8");
+ const bufB = Buffer.from(b, "utf8");
+ if (bufA.length !== bufB.length) {
+ return false;
+ }
+ return timingSafeEqual(bufA, bufB);
+}
+
+function getHostName(hostHeader?: string | string[]): string {
+ const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
+ .trim()
+ .toLowerCase();
+ if (!host) {
+ return "";
+ }
+ // Bracketed IPv6: [::1]:18789
+ if (host.startsWith("[")) {
+ const end = host.indexOf("]");
+ if (end !== -1) {
+ return host.slice(1, end);
}
- return null;
}
+ const [name] = host.split(":");
+ return name ?? "";
}
-function extractMessagePayload(payload: Record): Record | null {
- const dataRaw = payload.data ?? payload.payload ?? payload.event;
- const data =
- asRecord(dataRaw) ??
- (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
- const messageRaw = payload.message ?? data?.message ?? data;
- const message =
- asRecord(messageRaw) ??
- (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
- if (!message) {
- return null;
- }
- return message;
-}
-
-function normalizeWebhookMessage(
- payload: Record,
-): NormalizedWebhookMessage | null {
- const message = extractMessagePayload(payload);
- if (!message) {
- return null;
+function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
+ const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
+ const remoteIsLoopback =
+ remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
+ if (!remoteIsLoopback) {
+ return false;
}
- const text =
- readString(message, "text") ??
- readString(message, "body") ??
- readString(message, "subject") ??
- "";
-
- const handleValue = message.handle ?? message.sender;
- const handle =
- asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
- const senderId =
- readString(handle, "address") ??
- readString(handle, "handle") ??
- readString(handle, "id") ??
- readString(message, "senderId") ??
- readString(message, "sender") ??
- readString(message, "from") ??
- "";
-
- const senderName =
- readString(handle, "displayName") ??
- readString(handle, "name") ??
- readString(message, "senderName") ??
- undefined;
-
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
- const chatFromList = readFirstChatRecord(message);
- const chatGuid =
- readString(message, "chatGuid") ??
- readString(message, "chat_guid") ??
- readString(chat, "chatGuid") ??
- readString(chat, "chat_guid") ??
- readString(chat, "guid") ??
- readString(chatFromList, "chatGuid") ??
- readString(chatFromList, "chat_guid") ??
- readString(chatFromList, "guid");
- const chatIdentifier =
- readString(message, "chatIdentifier") ??
- readString(message, "chat_identifier") ??
- readString(chat, "chatIdentifier") ??
- readString(chat, "chat_identifier") ??
- readString(chat, "identifier") ??
- readString(chatFromList, "chatIdentifier") ??
- readString(chatFromList, "chat_identifier") ??
- readString(chatFromList, "identifier") ??
- extractChatIdentifierFromChatGuid(chatGuid);
- const chatId =
- readNumberLike(message, "chatId") ??
- readNumberLike(message, "chat_id") ??
- readNumberLike(chat, "chatId") ??
- readNumberLike(chat, "chat_id") ??
- readNumberLike(chat, "id") ??
- readNumberLike(chatFromList, "chatId") ??
- readNumberLike(chatFromList, "chat_id") ??
- readNumberLike(chatFromList, "id");
- const chatName =
- readString(message, "chatName") ??
- readString(chat, "displayName") ??
- readString(chat, "name") ??
- readString(chatFromList, "displayName") ??
- readString(chatFromList, "name") ??
- undefined;
-
- const chatParticipants = chat ? chat["participants"] : undefined;
- const messageParticipants = message["participants"];
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
- const participants = Array.isArray(chatParticipants)
- ? chatParticipants
- : Array.isArray(messageParticipants)
- ? messageParticipants
- : Array.isArray(chatsParticipants)
- ? chatsParticipants
- : [];
- const normalizedParticipants = normalizeParticipantList(participants);
- const participantsCount = participants.length;
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
- const explicitIsGroup =
- readBoolean(message, "isGroup") ??
- readBoolean(message, "is_group") ??
- readBoolean(chat, "isGroup") ??
- readBoolean(message, "group");
- const isGroup =
- typeof groupFromChatGuid === "boolean"
- ? groupFromChatGuid
- : (explicitIsGroup ?? participantsCount > 2);
-
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
- const messageId =
- readString(message, "guid") ??
- readString(message, "id") ??
- readString(message, "messageId") ??
- undefined;
- const balloonBundleId = readString(message, "balloonBundleId");
- const associatedMessageGuid =
- readString(message, "associatedMessageGuid") ??
- readString(message, "associated_message_guid") ??
- readString(message, "associatedMessageId") ??
- undefined;
- const associatedMessageType =
- readNumberLike(message, "associatedMessageType") ??
- readNumberLike(message, "associated_message_type");
- const associatedMessageEmoji =
- readString(message, "associatedMessageEmoji") ??
- readString(message, "associated_message_emoji") ??
- readString(message, "reactionEmoji") ??
- readString(message, "reaction_emoji") ??
- undefined;
- const isTapback =
- readBoolean(message, "isTapback") ??
- readBoolean(message, "is_tapback") ??
- readBoolean(message, "tapback") ??
- undefined;
-
- const timestampRaw =
- readNumber(message, "date") ??
- readNumber(message, "dateCreated") ??
- readNumber(message, "timestamp");
- const timestamp =
- typeof timestampRaw === "number"
- ? timestampRaw > 1_000_000_000_000
- ? timestampRaw
- : timestampRaw * 1000
- : undefined;
-
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
- if (!normalizedSender) {
- return null;
- }
- const replyMetadata = extractReplyMetadata(message);
-
- return {
- text,
- senderId: normalizedSender,
- senderName,
- messageId,
- timestamp,
- isGroup,
- chatId,
- chatGuid,
- chatIdentifier,
- chatName,
- fromMe,
- attachments: extractAttachments(message),
- balloonBundleId,
- associatedMessageGuid,
- associatedMessageType,
- associatedMessageEmoji,
- isTapback,
- participants: normalizedParticipants,
- replyToId: replyMetadata.replyToId,
- replyToBody: replyMetadata.replyToBody,
- replyToSender: replyMetadata.replyToSender,
- };
-}
-
-function normalizeWebhookReaction(
- payload: Record,
-): NormalizedWebhookReaction | null {
- const message = extractMessagePayload(payload);
- if (!message) {
- return null;
+ const host = getHostName(req.headers?.host);
+ const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
+ if (!hostIsLocal) {
+ return false;
}
- const associatedGuid =
- readString(message, "associatedMessageGuid") ??
- readString(message, "associated_message_guid") ??
- readString(message, "associatedMessageId");
- const associatedType =
- readNumberLike(message, "associatedMessageType") ??
- readNumberLike(message, "associated_message_type");
- if (!associatedGuid || associatedType === undefined) {
- return null;
- }
-
- const mapping = REACTION_TYPE_MAP.get(associatedType);
- const associatedEmoji =
- readString(message, "associatedMessageEmoji") ??
- readString(message, "associated_message_emoji") ??
- readString(message, "reactionEmoji") ??
- readString(message, "reaction_emoji");
- const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
- const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
-
- const handleValue = message.handle ?? message.sender;
- const handle =
- asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
- const senderId =
- readString(handle, "address") ??
- readString(handle, "handle") ??
- readString(handle, "id") ??
- readString(message, "senderId") ??
- readString(message, "sender") ??
- readString(message, "from") ??
- "";
- const senderName =
- readString(handle, "displayName") ??
- readString(handle, "name") ??
- readString(message, "senderName") ??
- undefined;
-
- const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
- const chatFromList = readFirstChatRecord(message);
- const chatGuid =
- readString(message, "chatGuid") ??
- readString(message, "chat_guid") ??
- readString(chat, "chatGuid") ??
- readString(chat, "chat_guid") ??
- readString(chat, "guid") ??
- readString(chatFromList, "chatGuid") ??
- readString(chatFromList, "chat_guid") ??
- readString(chatFromList, "guid");
- const chatIdentifier =
- readString(message, "chatIdentifier") ??
- readString(message, "chat_identifier") ??
- readString(chat, "chatIdentifier") ??
- readString(chat, "chat_identifier") ??
- readString(chat, "identifier") ??
- readString(chatFromList, "chatIdentifier") ??
- readString(chatFromList, "chat_identifier") ??
- readString(chatFromList, "identifier") ??
- extractChatIdentifierFromChatGuid(chatGuid);
- const chatId =
- readNumberLike(message, "chatId") ??
- readNumberLike(message, "chat_id") ??
- readNumberLike(chat, "chatId") ??
- readNumberLike(chat, "chat_id") ??
- readNumberLike(chat, "id") ??
- readNumberLike(chatFromList, "chatId") ??
- readNumberLike(chatFromList, "chat_id") ??
- readNumberLike(chatFromList, "id");
- const chatName =
- readString(message, "chatName") ??
- readString(chat, "displayName") ??
- readString(chat, "name") ??
- readString(chatFromList, "displayName") ??
- readString(chatFromList, "name") ??
- undefined;
-
- const chatParticipants = chat ? chat["participants"] : undefined;
- const messageParticipants = message["participants"];
- const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
- const participants = Array.isArray(chatParticipants)
- ? chatParticipants
- : Array.isArray(messageParticipants)
- ? messageParticipants
- : Array.isArray(chatsParticipants)
- ? chatsParticipants
- : [];
- const participantsCount = participants.length;
- const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
- const explicitIsGroup =
- readBoolean(message, "isGroup") ??
- readBoolean(message, "is_group") ??
- readBoolean(chat, "isGroup") ??
- readBoolean(message, "group");
- const isGroup =
- typeof groupFromChatGuid === "boolean"
- ? groupFromChatGuid
- : (explicitIsGroup ?? participantsCount > 2);
-
- const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
- const timestampRaw =
- readNumberLike(message, "date") ??
- readNumberLike(message, "dateCreated") ??
- readNumberLike(message, "timestamp");
- const timestamp =
- typeof timestampRaw === "number"
- ? timestampRaw > 1_000_000_000_000
- ? timestampRaw
- : timestampRaw * 1000
- : undefined;
-
- const normalizedSender = normalizeBlueBubblesHandle(senderId);
- if (!normalizedSender) {
- return null;
- }
-
- return {
- action,
- emoji,
- senderId: normalizedSender,
- senderName,
- messageId: associatedGuid,
- timestamp,
- isGroup,
- chatId,
- chatGuid,
- chatIdentifier,
- chatName,
- fromMe,
- };
+ // If a reverse proxy is in front, it will usually inject forwarding headers.
+ // Passwordless webhooks must never be accepted through a proxy.
+ const hasForwarded = Boolean(
+ req.headers?.["x-forwarded-for"] ||
+ req.headers?.["x-real-ip"] ||
+ req.headers?.["x-forwarded-host"],
+ );
+ return !hasForwarded;
}
export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise {
- const url = new URL(req.url ?? "/", "http://localhost");
- const path = normalizeWebhookPath(url.pathname);
- const targets = webhookTargets.get(path);
- if (!targets || targets.length === 0) {
+ const resolved = resolveWebhookTargets(req, webhookTargets);
+ if (!resolved) {
return false;
}
+ const { path, targets } = resolved;
+ const url = new URL(req.url ?? "/", "http://localhost");
- if (req.method !== "POST") {
- res.statusCode = 405;
- res.setHeader("Allow", "POST");
- res.end("Method Not Allowed");
+ if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const body = await readJsonBody(req, 1024 * 1024);
if (!body.ok) {
- res.statusCode = body.error === "payload too large" ? 413 : 400;
+ if (body.error === "payload too large") {
+ res.statusCode = 413;
+ } else if (body.error === "request body timeout") {
+ res.statusCode = 408;
+ } else {
+ res.statusCode = 400;
+ }
res.end(body.error ?? "invalid payload");
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
return true;
@@ -1518,23 +459,36 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
- const matching = targets.filter((target) => {
- const token = target.account.config.password?.trim();
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
+ const headerToken =
+ req.headers["x-guid"] ??
+ req.headers["x-password"] ??
+ req.headers["x-bluebubbles-guid"] ??
+ req.headers["authorization"];
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
+
+ const strictMatches: WebhookTarget[] = [];
+ const passwordlessTargets: WebhookTarget[] = [];
+ for (const target of targets) {
+ const token = target.account.config.password?.trim() ?? "";
if (!token) {
- return true;
+ passwordlessTargets.push(target);
+ continue;
}
- const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
- const headerToken =
- req.headers["x-guid"] ??
- req.headers["x-password"] ??
- req.headers["x-bluebubbles-guid"] ??
- req.headers["authorization"];
- const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
- if (guid && guid.trim() === token) {
- return true;
+ if (safeEqualSecret(guid, token)) {
+ strictMatches.push(target);
+ if (strictMatches.length > 1) {
+ break;
+ }
}
- return false;
- });
+ }
+
+ const matching =
+ strictMatches.length > 0
+ ? strictMatches
+ : isDirectLocalLoopbackRequest(req)
+ ? passwordlessTargets
+ : [];
if (matching.length === 0) {
res.statusCode = 401;
@@ -1545,24 +499,30 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
- for (const target of matching) {
- target.statusSink?.({ lastInboundAt: Date.now() });
- if (reaction) {
- processReaction(reaction, target).catch((err) => {
- target.runtime.error?.(
- `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
- );
- });
- } else if (message) {
- // Route messages through debouncer to coalesce rapid-fire events
- // (e.g., text message + URL balloon arriving as separate webhooks)
- const debouncer = getOrCreateDebouncer(target);
- debouncer.enqueue({ message, target }).catch((err) => {
- target.runtime.error?.(
- `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
- );
- });
- }
+ if (matching.length > 1) {
+ res.statusCode = 401;
+ res.end("ambiguous webhook target");
+ console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
+ return true;
+ }
+
+ const target = matching[0];
+ target.statusSink?.({ lastInboundAt: Date.now() });
+ if (reaction) {
+ processReaction(reaction, target).catch((err) => {
+ target.runtime.error?.(
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
+ );
+ });
+ } else if (message) {
+ // Route messages through debouncer to coalesce rapid-fire events
+ // (e.g., text message + URL balloon arriving as separate webhooks)
+ const debouncer = getOrCreateDebouncer(target);
+ debouncer.enqueue({ message, target }).catch((err) => {
+ target.runtime.error?.(
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
+ );
+ });
}
res.statusCode = 200;
@@ -1587,880 +547,6 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
-async function processMessage(
- message: NormalizedWebhookMessage,
- target: WebhookTarget,
-): Promise {
- const { account, config, runtime, core, statusSink } = target;
-
- const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
- const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
-
- const text = message.text.trim();
- const attachments = message.attachments ?? [];
- const placeholder = buildMessagePlaceholder(message);
- // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
- // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
- const tapbackContext = resolveTapbackContext(message);
- const tapbackParsed = parseTapbackText({
- text,
- emojiHint: tapbackContext?.emojiHint,
- actionHint: tapbackContext?.actionHint,
- requireQuoted: !tapbackContext,
- });
- const isTapbackMessage = Boolean(tapbackParsed);
- const rawBody = tapbackParsed
- ? tapbackParsed.action === "removed"
- ? `removed ${tapbackParsed.emoji} reaction`
- : `reacted with ${tapbackParsed.emoji}`
- : text || placeholder;
-
- const cacheMessageId = message.messageId?.trim();
- let messageShortId: string | undefined;
- const cacheInboundMessage = () => {
- if (!cacheMessageId) {
- return;
- }
- const cacheEntry = rememberBlueBubblesReplyCache({
- accountId: account.accountId,
- messageId: cacheMessageId,
- chatGuid: message.chatGuid,
- chatIdentifier: message.chatIdentifier,
- chatId: message.chatId,
- senderLabel: message.fromMe ? "me" : message.senderId,
- body: rawBody,
- timestamp: message.timestamp ?? Date.now(),
- });
- messageShortId = cacheEntry.shortId;
- };
-
- if (message.fromMe) {
- // Cache from-me messages so reply context can resolve sender/body.
- cacheInboundMessage();
- return;
- }
-
- if (!rawBody) {
- logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
- return;
- }
- logVerbose(
- core,
- runtime,
- `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
- );
-
- const dmPolicy = account.config.dmPolicy ?? "pairing";
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
- const storeAllowFrom = await core.channel.pairing
- .readAllowFromStore("bluebubbles")
- .catch(() => []);
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
- .map((entry) => String(entry).trim())
- .filter(Boolean);
- const effectiveGroupAllowFrom = [
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
- ...storeAllowFrom,
- ]
- .map((entry) => String(entry).trim())
- .filter(Boolean);
- const groupAllowEntry = formatGroupAllowlistEntry({
- chatGuid: message.chatGuid,
- chatId: message.chatId ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- });
- const groupName = message.chatName?.trim() || undefined;
-
- if (isGroup) {
- if (groupPolicy === "disabled") {
- logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
- logGroupAllowlistHint({
- runtime,
- reason: "groupPolicy=disabled",
- entry: groupAllowEntry,
- chatName: groupName,
- accountId: account.accountId,
- });
- return;
- }
- if (groupPolicy === "allowlist") {
- if (effectiveGroupAllowFrom.length === 0) {
- logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
- logGroupAllowlistHint({
- runtime,
- reason: "groupPolicy=allowlist (empty allowlist)",
- entry: groupAllowEntry,
- chatName: groupName,
- accountId: account.accountId,
- });
- return;
- }
- const allowed = isAllowedBlueBubblesSender({
- allowFrom: effectiveGroupAllowFrom,
- sender: message.senderId,
- chatId: message.chatId ?? undefined,
- chatGuid: message.chatGuid ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- });
- if (!allowed) {
- logVerbose(
- core,
- runtime,
- `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
- );
- logVerbose(
- core,
- runtime,
- `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
- );
- logGroupAllowlistHint({
- runtime,
- reason: "groupPolicy=allowlist (not allowlisted)",
- entry: groupAllowEntry,
- chatName: groupName,
- accountId: account.accountId,
- });
- return;
- }
- }
- } else {
- if (dmPolicy === "disabled") {
- logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
- logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
- return;
- }
- if (dmPolicy !== "open") {
- const allowed = isAllowedBlueBubblesSender({
- allowFrom: effectiveAllowFrom,
- sender: message.senderId,
- chatId: message.chatId ?? undefined,
- chatGuid: message.chatGuid ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- });
- if (!allowed) {
- if (dmPolicy === "pairing") {
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
- channel: "bluebubbles",
- id: message.senderId,
- meta: { name: message.senderName },
- });
- runtime.log?.(
- `[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
- );
- if (created) {
- logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
- try {
- await sendMessageBlueBubbles(
- message.senderId,
- core.channel.pairing.buildPairingReply({
- channel: "bluebubbles",
- idLine: `Your BlueBubbles sender id: ${message.senderId}`,
- code,
- }),
- { cfg: config, accountId: account.accountId },
- );
- statusSink?.({ lastOutboundAt: Date.now() });
- } catch (err) {
- logVerbose(
- core,
- runtime,
- `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
- );
- runtime.error?.(
- `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
- );
- }
- }
- } else {
- logVerbose(
- core,
- runtime,
- `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
- );
- logVerbose(
- core,
- runtime,
- `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
- );
- }
- return;
- }
- }
- }
-
- const chatId = message.chatId ?? undefined;
- const chatGuid = message.chatGuid ?? undefined;
- const chatIdentifier = message.chatIdentifier ?? undefined;
- const peerId = isGroup
- ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
- : message.senderId;
-
- const route = core.channel.routing.resolveAgentRoute({
- cfg: config,
- channel: "bluebubbles",
- accountId: account.accountId,
- peer: {
- kind: isGroup ? "group" : "direct",
- id: peerId,
- },
- });
-
- // Mention gating for group chats (parity with iMessage/WhatsApp)
- const messageText = text;
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
- const wasMentioned = isGroup
- ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
- : true;
- const canDetectMention = mentionRegexes.length > 0;
- const requireMention = core.channel.groups.resolveRequireMention({
- cfg: config,
- channel: "bluebubbles",
- groupId: peerId,
- accountId: account.accountId,
- });
-
- // Command gating (parity with iMessage/WhatsApp)
- const useAccessGroups = config.commands?.useAccessGroups !== false;
- const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
- const ownerAllowedForCommands =
- effectiveAllowFrom.length > 0
- ? isAllowedBlueBubblesSender({
- allowFrom: effectiveAllowFrom,
- sender: message.senderId,
- chatId: message.chatId ?? undefined,
- chatGuid: message.chatGuid ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- })
- : false;
- const groupAllowedForCommands =
- effectiveGroupAllowFrom.length > 0
- ? isAllowedBlueBubblesSender({
- allowFrom: effectiveGroupAllowFrom,
- sender: message.senderId,
- chatId: message.chatId ?? undefined,
- chatGuid: message.chatGuid ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- })
- : false;
- const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
- const commandGate = resolveControlCommandGate({
- useAccessGroups,
- authorizers: [
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
- ],
- allowTextCommands: true,
- hasControlCommand: hasControlCmd,
- });
- const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
-
- // Block control commands from unauthorized senders in groups
- if (isGroup && commandGate.shouldBlock) {
- logInboundDrop({
- log: (msg) => logVerbose(core, runtime, msg),
- channel: "bluebubbles",
- reason: "control command (unauthorized)",
- target: message.senderId,
- });
- return;
- }
-
- // Allow control commands to bypass mention gating when authorized (parity with iMessage)
- const shouldBypassMention =
- isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
- const effectiveWasMentioned = wasMentioned || shouldBypassMention;
-
- // Skip group messages that require mention but weren't mentioned
- if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
- logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
- return;
- }
-
- // Cache allowed inbound messages so later replies can resolve sender/body without
- // surfacing dropped content (allowlist/mention/command gating).
- cacheInboundMessage();
-
- const baseUrl = account.config.serverUrl?.trim();
- const password = account.config.password?.trim();
- const maxBytes =
- account.config.mediaMaxMb && account.config.mediaMaxMb > 0
- ? account.config.mediaMaxMb * 1024 * 1024
- : 8 * 1024 * 1024;
-
- let mediaUrls: string[] = [];
- let mediaPaths: string[] = [];
- let mediaTypes: string[] = [];
- if (attachments.length > 0) {
- if (!baseUrl || !password) {
- logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
- } else {
- for (const attachment of attachments) {
- if (!attachment.guid) {
- continue;
- }
- if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
- logVerbose(
- core,
- runtime,
- `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
- );
- continue;
- }
- try {
- const downloaded = await downloadBlueBubblesAttachment(attachment, {
- cfg: config,
- accountId: account.accountId,
- maxBytes,
- });
- const saved = await core.channel.media.saveMediaBuffer(
- Buffer.from(downloaded.buffer),
- downloaded.contentType,
- "inbound",
- maxBytes,
- );
- mediaPaths.push(saved.path);
- mediaUrls.push(saved.path);
- if (saved.contentType) {
- mediaTypes.push(saved.contentType);
- }
- } catch (err) {
- logVerbose(
- core,
- runtime,
- `attachment download failed guid=${attachment.guid} err=${String(err)}`,
- );
- }
- }
- }
- }
- let replyToId = message.replyToId;
- let replyToBody = message.replyToBody;
- let replyToSender = message.replyToSender;
- let replyToShortId: string | undefined;
-
- if (isTapbackMessage && tapbackContext?.replyToId) {
- replyToId = tapbackContext.replyToId;
- }
-
- if (replyToId) {
- const cached = resolveReplyContextFromCache({
- accountId: account.accountId,
- replyToId,
- chatGuid: message.chatGuid,
- chatIdentifier: message.chatIdentifier,
- chatId: message.chatId,
- });
- if (cached) {
- if (!replyToBody && cached.body) {
- replyToBody = cached.body;
- }
- if (!replyToSender && cached.senderLabel) {
- replyToSender = cached.senderLabel;
- }
- replyToShortId = cached.shortId;
- if (core.logging.shouldLogVerbose()) {
- const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
- logVerbose(
- core,
- runtime,
- `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
- );
- }
- }
- }
-
- // If no cached short ID, try to get one from the UUID directly
- if (replyToId && !replyToShortId) {
- replyToShortId = getShortIdForUuid(replyToId);
- }
-
- // Use inline [[reply_to:N]] tag format
- // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
- // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
- const replyTag = formatReplyTag({ replyToId, replyToShortId });
- const baseBody = replyTag
- ? isTapbackMessage
- ? `${rawBody} ${replyTag}`
- : `${replyTag} ${rawBody}`
- : rawBody;
- const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
- const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
- const groupMembers = isGroup
- ? formatGroupMembers({
- participants: message.participants,
- fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
- })
- : undefined;
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
- agentId: route.agentId,
- });
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
- storePath,
- sessionKey: route.sessionKey,
- });
- const body = core.channel.reply.formatAgentEnvelope({
- channel: "BlueBubbles",
- from: fromLabel,
- timestamp: message.timestamp,
- previousTimestamp,
- envelope: envelopeOptions,
- body: baseBody,
- });
- let chatGuidForActions = chatGuid;
- if (!chatGuidForActions && baseUrl && password) {
- const target =
- isGroup && (chatId || chatIdentifier)
- ? chatId
- ? ({ kind: "chat_id", chatId } as const)
- : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
- : ({ kind: "handle", address: message.senderId } as const);
- if (target.kind !== "chat_identifier" || target.chatIdentifier) {
- chatGuidForActions =
- (await resolveChatGuidForTarget({
- baseUrl,
- password,
- target,
- })) ?? undefined;
- }
- }
-
- const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
- const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
- const ackReactionValue = resolveBlueBubblesAckReaction({
- cfg: config,
- agentId: route.agentId,
- core,
- runtime,
- });
- const shouldAckReaction = () =>
- Boolean(
- ackReactionValue &&
- core.channel.reactions.shouldAckReaction({
- scope: ackReactionScope,
- isDirect: !isGroup,
- isGroup,
- isMentionableGroup: isGroup,
- requireMention: Boolean(requireMention),
- canDetectMention,
- effectiveWasMentioned,
- shouldBypassMention,
- }),
- );
- const ackMessageId = message.messageId?.trim() || "";
- const ackReactionPromise =
- shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
- ? sendBlueBubblesReaction({
- chatGuid: chatGuidForActions,
- messageGuid: ackMessageId,
- emoji: ackReactionValue,
- opts: { cfg: config, accountId: account.accountId },
- }).then(
- () => true,
- (err) => {
- logVerbose(
- core,
- runtime,
- `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
- );
- return false;
- },
- )
- : null;
-
- // Respect sendReadReceipts config (parity with WhatsApp)
- const sendReadReceipts = account.config.sendReadReceipts !== false;
- if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
- try {
- await markBlueBubblesChatRead(chatGuidForActions, {
- cfg: config,
- accountId: account.accountId,
- });
- logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
- } catch (err) {
- runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
- }
- } else if (!sendReadReceipts) {
- logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
- } else {
- logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
- }
-
- const outboundTarget = isGroup
- ? formatBlueBubblesChatTarget({
- chatId,
- chatGuid: chatGuidForActions ?? chatGuid,
- chatIdentifier,
- }) || peerId
- : chatGuidForActions
- ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
- : message.senderId;
-
- const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
- const trimmed = messageId?.trim();
- if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
- return;
- }
- // Cache outbound message to get short ID
- const cacheEntry = rememberBlueBubblesReplyCache({
- accountId: account.accountId,
- messageId: trimmed,
- chatGuid: chatGuidForActions ?? chatGuid,
- chatIdentifier,
- chatId,
- senderLabel: "me",
- body: snippet ?? "",
- timestamp: Date.now(),
- });
- const displayId = cacheEntry.shortId || trimmed;
- const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
- core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
- sessionKey: route.sessionKey,
- contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
- });
- };
-
- const ctxPayload = {
- Body: body,
- BodyForAgent: body,
- RawBody: rawBody,
- CommandBody: rawBody,
- BodyForCommands: rawBody,
- MediaUrl: mediaUrls[0],
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
- MediaPath: mediaPaths[0],
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
- MediaType: mediaTypes[0],
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
- From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
- To: `bluebubbles:${outboundTarget}`,
- SessionKey: route.sessionKey,
- AccountId: route.accountId,
- ChatType: isGroup ? "group" : "direct",
- ConversationLabel: fromLabel,
- // Use short ID for token savings (agent can use this to reference the message)
- ReplyToId: replyToShortId || replyToId,
- ReplyToIdFull: replyToId,
- ReplyToBody: replyToBody,
- ReplyToSender: replyToSender,
- GroupSubject: groupSubject,
- GroupMembers: groupMembers,
- SenderName: message.senderName || undefined,
- SenderId: message.senderId,
- Provider: "bluebubbles",
- Surface: "bluebubbles",
- // Use short ID for token savings (agent can use this to reference the message)
- MessageSid: messageShortId || message.messageId,
- MessageSidFull: message.messageId,
- Timestamp: message.timestamp,
- OriginatingChannel: "bluebubbles",
- OriginatingTo: `bluebubbles:${outboundTarget}`,
- WasMentioned: effectiveWasMentioned,
- CommandAuthorized: commandAuthorized,
- };
-
- let sentMessage = false;
- let streamingActive = false;
- let typingRestartTimer: NodeJS.Timeout | undefined;
- const typingRestartDelayMs = 150;
- const clearTypingRestartTimer = () => {
- if (typingRestartTimer) {
- clearTimeout(typingRestartTimer);
- typingRestartTimer = undefined;
- }
- };
- const restartTypingSoon = () => {
- if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
- return;
- }
- clearTypingRestartTimer();
- typingRestartTimer = setTimeout(() => {
- typingRestartTimer = undefined;
- if (!streamingActive) {
- return;
- }
- sendBlueBubblesTyping(chatGuidForActions, true, {
- cfg: config,
- accountId: account.accountId,
- }).catch((err) => {
- runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
- });
- }, typingRestartDelayMs);
- };
- try {
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
- cfg: config,
- agentId: route.agentId,
- channel: "bluebubbles",
- accountId: account.accountId,
- });
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
- ctx: ctxPayload,
- cfg: config,
- dispatcherOptions: {
- ...prefixOptions,
- deliver: async (payload, info) => {
- const rawReplyToId =
- typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
- // Resolve short ID (e.g., "5") to full UUID
- const replyToMessageGuid = rawReplyToId
- ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
- : "";
- const mediaList = payload.mediaUrls?.length
- ? payload.mediaUrls
- : payload.mediaUrl
- ? [payload.mediaUrl]
- : [];
- if (mediaList.length > 0) {
- const tableMode = core.channel.text.resolveMarkdownTableMode({
- cfg: config,
- channel: "bluebubbles",
- accountId: account.accountId,
- });
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
- let first = true;
- for (const mediaUrl of mediaList) {
- const caption = first ? text : undefined;
- first = false;
- const result = await sendBlueBubblesMedia({
- cfg: config,
- to: outboundTarget,
- mediaUrl,
- caption: caption ?? undefined,
- replyToId: replyToMessageGuid || null,
- accountId: account.accountId,
- });
- const cachedBody = (caption ?? "").trim() || "";
- maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
- sentMessage = true;
- statusSink?.({ lastOutboundAt: Date.now() });
- if (info.kind === "block") {
- restartTypingSoon();
- }
- }
- return;
- }
-
- const textLimit =
- account.config.textChunkLimit && account.config.textChunkLimit > 0
- ? account.config.textChunkLimit
- : DEFAULT_TEXT_LIMIT;
- const chunkMode = account.config.chunkMode ?? "length";
- const tableMode = core.channel.text.resolveMarkdownTableMode({
- cfg: config,
- channel: "bluebubbles",
- accountId: account.accountId,
- });
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
- const chunks =
- chunkMode === "newline"
- ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
- : core.channel.text.chunkMarkdownText(text, textLimit);
- if (!chunks.length && text) {
- chunks.push(text);
- }
- if (!chunks.length) {
- return;
- }
- for (let i = 0; i < chunks.length; i++) {
- const chunk = chunks[i];
- const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
- cfg: config,
- accountId: account.accountId,
- replyToMessageGuid: replyToMessageGuid || undefined,
- });
- maybeEnqueueOutboundMessageId(result.messageId, chunk);
- sentMessage = true;
- statusSink?.({ lastOutboundAt: Date.now() });
- if (info.kind === "block") {
- restartTypingSoon();
- }
- }
- },
- onReplyStart: async () => {
- if (!chatGuidForActions) {
- return;
- }
- if (!baseUrl || !password) {
- return;
- }
- streamingActive = true;
- clearTypingRestartTimer();
- try {
- await sendBlueBubblesTyping(chatGuidForActions, true, {
- cfg: config,
- accountId: account.accountId,
- });
- } catch (err) {
- runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
- }
- },
- onIdle: async () => {
- if (!chatGuidForActions) {
- return;
- }
- if (!baseUrl || !password) {
- return;
- }
- // Intentionally no-op for block streaming. We stop typing in finally
- // after the run completes to avoid flicker between paragraph blocks.
- },
- onError: (err, info) => {
- runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
- },
- },
- replyOptions: {
- onModelSelected,
- disableBlockStreaming:
- typeof account.config.blockStreaming === "boolean"
- ? !account.config.blockStreaming
- : undefined,
- },
- });
- } finally {
- const shouldStopTyping =
- Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
- streamingActive = false;
- clearTypingRestartTimer();
- if (sentMessage && chatGuidForActions && ackMessageId) {
- core.channel.reactions.removeAckReactionAfterReply({
- removeAfterReply: removeAckAfterReply,
- ackReactionPromise,
- ackReactionValue: ackReactionValue ?? null,
- remove: () =>
- sendBlueBubblesReaction({
- chatGuid: chatGuidForActions,
- messageGuid: ackMessageId,
- emoji: ackReactionValue ?? "",
- remove: true,
- opts: { cfg: config, accountId: account.accountId },
- }),
- onError: (err) => {
- logAckFailure({
- log: (msg) => logVerbose(core, runtime, msg),
- channel: "bluebubbles",
- target: `${chatGuidForActions}/${ackMessageId}`,
- error: err,
- });
- },
- });
- }
- if (shouldStopTyping && chatGuidForActions) {
- // Stop typing after streaming completes to avoid a stuck indicator.
- sendBlueBubblesTyping(chatGuidForActions, false, {
- cfg: config,
- accountId: account.accountId,
- }).catch((err) => {
- logTypingFailure({
- log: (msg) => logVerbose(core, runtime, msg),
- channel: "bluebubbles",
- action: "stop",
- target: chatGuidForActions,
- error: err,
- });
- });
- }
- }
-}
-
-async function processReaction(
- reaction: NormalizedWebhookReaction,
- target: WebhookTarget,
-): Promise {
- const { account, config, runtime, core } = target;
- if (reaction.fromMe) {
- return;
- }
-
- const dmPolicy = account.config.dmPolicy ?? "pairing";
- const groupPolicy = account.config.groupPolicy ?? "allowlist";
- const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
- const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
- const storeAllowFrom = await core.channel.pairing
- .readAllowFromStore("bluebubbles")
- .catch(() => []);
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
- .map((entry) => String(entry).trim())
- .filter(Boolean);
- const effectiveGroupAllowFrom = [
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
- ...storeAllowFrom,
- ]
- .map((entry) => String(entry).trim())
- .filter(Boolean);
-
- if (reaction.isGroup) {
- if (groupPolicy === "disabled") {
- return;
- }
- if (groupPolicy === "allowlist") {
- if (effectiveGroupAllowFrom.length === 0) {
- return;
- }
- const allowed = isAllowedBlueBubblesSender({
- allowFrom: effectiveGroupAllowFrom,
- sender: reaction.senderId,
- chatId: reaction.chatId ?? undefined,
- chatGuid: reaction.chatGuid ?? undefined,
- chatIdentifier: reaction.chatIdentifier ?? undefined,
- });
- if (!allowed) {
- return;
- }
- }
- } else {
- if (dmPolicy === "disabled") {
- return;
- }
- if (dmPolicy !== "open") {
- const allowed = isAllowedBlueBubblesSender({
- allowFrom: effectiveAllowFrom,
- sender: reaction.senderId,
- chatId: reaction.chatId ?? undefined,
- chatGuid: reaction.chatGuid ?? undefined,
- chatIdentifier: reaction.chatIdentifier ?? undefined,
- });
- if (!allowed) {
- return;
- }
- }
- }
-
- const chatId = reaction.chatId ?? undefined;
- const chatGuid = reaction.chatGuid ?? undefined;
- const chatIdentifier = reaction.chatIdentifier ?? undefined;
- const peerId = reaction.isGroup
- ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
- : reaction.senderId;
-
- const route = core.channel.routing.resolveAgentRoute({
- cfg: config,
- channel: "bluebubbles",
- accountId: account.accountId,
- peer: {
- kind: reaction.isGroup ? "group" : "direct",
- id: peerId,
- },
- });
-
- const senderLabel = reaction.senderName || reaction.senderId;
- const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
- // Use short ID for token savings
- const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
- // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
- const text =
- reaction.action === "removed"
- ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
- : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
- core.system.enqueueSystemEvent(text, {
- sessionKey: route.sessionKey,
- contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
- });
- logVerbose(core, runtime, `reaction event enqueued: ${text}`);
-}
-
export async function monitorBlueBubblesProvider(
options: BlueBubblesMonitorOptions,
): Promise {
@@ -2478,6 +564,11 @@ export async function monitorBlueBubblesProvider(
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
}
+ if (typeof serverInfo?.private_api === "boolean") {
+ runtime.log?.(
+ `[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
+ );
+ }
const unregister = registerBlueBubblesWebhookTarget({
account,
@@ -2506,10 +597,4 @@ export async function monitorBlueBubblesProvider(
});
}
-export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
- const raw = config?.webhookPath?.trim();
- if (raw) {
- return normalizeWebhookPath(raw);
- }
- return DEFAULT_WEBHOOK_PATH;
-}
+export { _resetBlueBubblesShortIdState, resolveBlueBubblesMessageId, resolveWebhookPathFromConfig };
diff --git a/extensions/bluebubbles/src/multipart.ts b/extensions/bluebubbles/src/multipart.ts
new file mode 100644
index 00000000000..851cca016b7
--- /dev/null
+++ b/extensions/bluebubbles/src/multipart.ts
@@ -0,0 +1,32 @@
+import { blueBubblesFetchWithTimeout } from "./types.js";
+
+export function concatUint8Arrays(parts: Uint8Array[]): Uint8Array {
+ const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
+ const body = new Uint8Array(totalLength);
+ let offset = 0;
+ for (const part of parts) {
+ body.set(part, offset);
+ offset += part.length;
+ }
+ return body;
+}
+
+export async function postMultipartFormData(params: {
+ url: string;
+ boundary: string;
+ parts: Uint8Array[];
+ timeoutMs: number;
+}): Promise {
+ const body = Buffer.from(concatUint8Arrays(params.parts));
+ return await blueBubblesFetchWithTimeout(
+ params.url,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": `multipart/form-data; boundary=${params.boundary}`,
+ },
+ body,
+ },
+ params.timeoutMs,
+ );
+}
diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts
index 1d68ace62fb..ca6b42ab5df 100644
--- a/extensions/bluebubbles/src/onboarding.ts
+++ b/extensions/bluebubbles/src/onboarding.ts
@@ -9,6 +9,7 @@ import {
DEFAULT_ACCOUNT_ID,
addWildcardAllowFrom,
formatDocsLink,
+ mergeAllowFromEntries,
normalizeAccountId,
promptAccountId,
} from "openclaw/plugin-sdk";
@@ -127,7 +128,7 @@ async function promptBlueBubblesAllowFrom(params: {
},
});
const parts = parseBlueBubblesAllowFromInput(String(entry));
- const unique = [...new Set(parts)];
+ const unique = mergeAllowFromEntries(undefined, parts);
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
}
diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts
index d87a6d44714..e60c47dc643 100644
--- a/extensions/bluebubbles/src/probe.ts
+++ b/extensions/bluebubbles/src/probe.ts
@@ -1,9 +1,8 @@
+import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
-export type BlueBubblesProbe = {
- ok: boolean;
+export type BlueBubblesProbe = BaseProbeResult & {
status?: number | null;
- error?: string | null;
};
export type BlueBubblesServerInfo = {
@@ -85,6 +84,18 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS
return null;
}
+/**
+ * Read cached private API capability for a BlueBubbles account.
+ * Returns null when capability is unknown (for example, before first probe).
+ */
+export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
+ const info = getCachedBlueBubblesServerInfo(accountId);
+ if (!info || typeof info.private_api !== "boolean") {
+ return null;
+ }
+ return info.private_api;
+}
+
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/
diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts
index 5b59eda0d88..69d5b2055cc 100644
--- a/extensions/bluebubbles/src/reactions.ts
+++ b/extensions/bluebubbles/src/reactions.ts
@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
-import { resolveBlueBubblesAccount } from "./accounts.js";
+import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesReactionOpts = {
@@ -111,19 +112,7 @@ const REACTION_EMOJIS = new Map([
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
- const account = resolveBlueBubblesAccount({
- cfg: params.cfg ?? {},
- accountId: params.accountId,
- });
- const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
- const password = params.password?.trim() || account.config.password?.trim();
- if (!baseUrl) {
- throw new Error("BlueBubbles serverUrl is required");
- }
- if (!password) {
- throw new Error("BlueBubbles password is required");
- }
- return { baseUrl, password };
+ return resolveBlueBubblesServerAccount(params);
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
@@ -160,7 +149,12 @@ export async function sendBlueBubblesReaction(params: {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
- const { baseUrl, password } = resolveAccount(params.opts ?? {});
+ const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {});
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
+ throw new Error(
+ "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
+ );
+ }
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/react",
diff --git a/extensions/bluebubbles/src/send-helpers.ts b/extensions/bluebubbles/src/send-helpers.ts
new file mode 100644
index 00000000000..53e03a92c8c
--- /dev/null
+++ b/extensions/bluebubbles/src/send-helpers.ts
@@ -0,0 +1,53 @@
+import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
+import type { BlueBubblesSendTarget } from "./types.js";
+
+export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
+ const parsed = parseBlueBubblesTarget(raw);
+ if (parsed.kind === "handle") {
+ return {
+ kind: "handle",
+ address: normalizeBlueBubblesHandle(parsed.to),
+ service: parsed.service,
+ };
+ }
+ if (parsed.kind === "chat_id") {
+ return { kind: "chat_id", chatId: parsed.chatId };
+ }
+ if (parsed.kind === "chat_guid") {
+ return { kind: "chat_guid", chatGuid: parsed.chatGuid };
+ }
+ return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
+}
+
+export function extractBlueBubblesMessageId(payload: unknown): string {
+ if (!payload || typeof payload !== "object") {
+ return "unknown";
+ }
+ const record = payload as Record;
+ const data =
+ record.data && typeof record.data === "object"
+ ? (record.data as Record)
+ : null;
+ const candidates = [
+ record.messageId,
+ record.messageGuid,
+ record.message_guid,
+ record.guid,
+ record.id,
+ data?.messageId,
+ data?.messageGuid,
+ data?.message_guid,
+ data?.message_id,
+ data?.guid,
+ data?.id,
+ ];
+ for (const candidate of candidates) {
+ if (typeof candidate === "string" && candidate.trim()) {
+ return candidate.trim();
+ }
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
+ return String(candidate);
+ }
+ }
+ return "unknown";
+}
diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts
index c10266068fc..c1bcafe29cb 100644
--- a/extensions/bluebubbles/src/send.test.ts
+++ b/extensions/bluebubbles/src/send.test.ts
@@ -1,32 +1,62 @@
-import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
-import type { BlueBubblesSendTarget } from "./types.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import "./test-mocks.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.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,
- };
- }),
-}));
+import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
+import type { BlueBubblesSendTarget } from "./types.js";
const mockFetch = vi.fn();
+installBlueBubblesFetchTestHooks({
+ mockFetch,
+ privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
+});
+
+function mockResolvedHandleTarget(
+ guid: string = "iMessage;-;+15551234567",
+ address: string = "+15551234567",
+) {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ data: [
+ {
+ guid,
+ participants: [{ address }],
+ },
+ ],
+ }),
+ });
+}
+
+function mockSendResponse(body: unknown) {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(body)),
+ });
+}
+
describe("send", () => {
- beforeEach(() => {
- vi.stubGlobal("fetch", mockFetch);
- mockFetch.mockReset();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
describe("resolveChatGuidForTarget", () => {
+ const resolveHandleTargetGuid = async (data: Array>) => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ data }),
+ });
+
+ const target: BlueBubblesSendTarget = {
+ kind: "handle",
+ address: "+15551234567",
+ service: "imessage",
+ };
+ return await resolveChatGuidForTarget({
+ baseUrl: "http://localhost:1234",
+ password: "test",
+ target,
+ });
+ };
+
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
@@ -123,65 +153,31 @@ describe("send", () => {
});
it("resolves handle target by matching participant", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15559999999",
- participants: [{ address: "+15559999999" }],
- },
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- });
-
- const target: BlueBubblesSendTarget = {
- kind: "handle",
- address: "+15551234567",
- service: "imessage",
- };
- const result = await resolveChatGuidForTarget({
- baseUrl: "http://localhost:1234",
- password: "test",
- target,
- });
+ const result = await resolveHandleTargetGuid([
+ {
+ guid: "iMessage;-;+15559999999",
+ participants: [{ address: "+15559999999" }],
+ },
+ {
+ guid: "iMessage;-;+15551234567",
+ participants: [{ address: "+15551234567" }],
+ },
+ ]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;+;group-123",
- participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
- },
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- });
-
- const target: BlueBubblesSendTarget = {
- kind: "handle",
- address: "+15551234567",
- service: "imessage",
- };
- const result = await resolveChatGuidForTarget({
- baseUrl: "http://localhost:1234",
- password: "test",
- target,
- });
+ const result = await resolveHandleTargetGuid([
+ {
+ guid: "iMessage;+;group-123",
+ participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
+ },
+ {
+ guid: "iMessage;-;+15551234567",
+ participants: [{ address: "+15551234567" }],
+ },
+ ]);
expect(result).toBe("iMessage;-;+15551234567");
});
@@ -409,28 +405,8 @@ describe("send", () => {
});
it("sends message successfully", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- data: { guid: "msg-uuid-123" },
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-uuid-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
@@ -449,28 +425,8 @@ describe("send", () => {
});
it("strips markdown formatting from outbound messages", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- data: { guid: "msg-uuid-stripped" },
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
const result = await sendMessageBlueBubbles(
"+15551234567",
@@ -571,28 +527,8 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- data: { guid: "msg-uuid-124" },
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-uuid-124" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
@@ -611,29 +547,29 @@ describe("send", () => {
expect(body.partIndex).toBe(1);
});
+ it("downgrades threaded reply to plain send when private API is disabled", async () => {
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-uuid-plain" } });
+
+ const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
+ serverUrl: "http://localhost:1234",
+ password: "test",
+ replyToMessageGuid: "reply-guid-123",
+ replyToPartIndex: 1,
+ });
+
+ expect(result.messageId).toBe("msg-uuid-plain");
+ 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();
+ });
+
it("normalizes effect names and uses private-api for effects", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- data: { guid: "msg-uuid-125" },
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-uuid-125" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -675,24 +611,12 @@ describe("send", () => {
});
it("handles send failure", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: false,
- status: 500,
- text: () => Promise.resolve("Internal server error"),
- });
+ mockResolvedHandleTarget();
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ text: () => Promise.resolve("Internal server error"),
+ });
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -703,23 +627,11 @@ describe("send", () => {
});
it("handles empty response body", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () => Promise.resolve(""),
- });
+ mockResolvedHandleTarget();
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(""),
+ });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -730,23 +642,11 @@ describe("send", () => {
});
it("handles invalid JSON response body", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () => Promise.resolve("not valid json"),
- });
+ mockResolvedHandleTarget();
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve("not valid json"),
+ });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -757,28 +657,8 @@ describe("send", () => {
});
it("extracts messageId from various response formats", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- id: "numeric-id-456",
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ id: "numeric-id-456" });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -789,28 +669,8 @@ describe("send", () => {
});
it("extracts messageGuid from response payload", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () =>
- Promise.resolve(
- JSON.stringify({
- data: { messageGuid: "msg-guid-789" },
- }),
- ),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -821,23 +681,8 @@ describe("send", () => {
});
it("resolves credentials from config", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
@@ -856,23 +701,8 @@ describe("send", () => {
});
it("includes tempGuid in request payload", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- data: [
- {
- guid: "iMessage;-;+15551234567",
- participants: [{ address: "+15551234567" }],
- },
- ],
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
- });
+ mockResolvedHandleTarget();
+ mockSendResponse({ data: { guid: "msg" } });
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts
index 4a6a369dd56..c5614062f51 100644
--- a/extensions/bluebubbles/src/send.ts
+++ b/extensions/bluebubbles/src/send.ts
@@ -1,12 +1,10 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
-import {
- extractHandleFromChatGuid,
- normalizeBlueBubblesHandle,
- parseBlueBubblesTarget,
-} from "./targets.js";
+import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
+import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
@@ -73,57 +71,6 @@ function resolveEffectId(raw?: string): string | undefined {
return raw;
}
-function resolveSendTarget(raw: string): BlueBubblesSendTarget {
- const parsed = parseBlueBubblesTarget(raw);
- if (parsed.kind === "handle") {
- return {
- kind: "handle",
- address: normalizeBlueBubblesHandle(parsed.to),
- service: parsed.service,
- };
- }
- if (parsed.kind === "chat_id") {
- return { kind: "chat_id", chatId: parsed.chatId };
- }
- if (parsed.kind === "chat_guid") {
- return { kind: "chat_guid", chatGuid: parsed.chatGuid };
- }
- return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
-}
-
-function extractMessageId(payload: unknown): string {
- if (!payload || typeof payload !== "object") {
- return "unknown";
- }
- const record = payload as Record;
- const data =
- record.data && typeof record.data === "object"
- ? (record.data as Record)
- : null;
- const candidates = [
- record.messageId,
- record.messageGuid,
- record.message_guid,
- record.guid,
- record.id,
- data?.messageId,
- data?.messageGuid,
- data?.message_guid,
- data?.message_id,
- data?.guid,
- data?.id,
- ];
- for (const candidate of candidates) {
- if (typeof candidate === "string" && candidate.trim()) {
- return candidate.trim();
- }
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
- return String(candidate);
- }
- }
- return "unknown";
-}
-
type BlueBubblesChatRecord = Record;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -364,7 +311,7 @@ async function createNewChatWithMessage(params: {
}
try {
const parsed = JSON.parse(body) as unknown;
- return { messageId: extractMessageId(parsed) };
+ return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
@@ -397,8 +344,9 @@ export async function sendMessageBlueBubbles(
if (!password) {
throw new Error("BlueBubbles password is required");
}
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
- const target = resolveSendTarget(to);
+ const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
@@ -422,18 +370,26 @@ export async function sendMessageBlueBubbles(
);
}
const effectId = resolveEffectId(opts.effectId);
- const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
+ const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
+ const wantsEffect = Boolean(effectId);
+ const needsPrivateApi = wantsReplyThread || wantsEffect;
+ const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
+ if (wantsEffect && privateApiStatus === false) {
+ throw new Error(
+ "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
+ );
+ }
const payload: Record = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
- if (needsPrivateApi) {
+ if (canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
- if (opts.replyToMessageGuid) {
+ if (wantsReplyThread && canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
@@ -467,7 +423,7 @@ export async function sendMessageBlueBubbles(
}
try {
const parsed = JSON.parse(body) as unknown;
- return { messageId: extractMessageId(parsed) };
+ return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts
index 738e144da30..be9d0fa6770 100644
--- a/extensions/bluebubbles/src/targets.ts
+++ b/extensions/bluebubbles/src/targets.ts
@@ -1,3 +1,11 @@
+import {
+ isAllowedParsedChatSender,
+ parseChatAllowTargetPrefixes,
+ parseChatTargetPrefixesOrThrow,
+ resolveServicePrefixedAllowTarget,
+ resolveServicePrefixedTarget,
+} from "openclaw/plugin-sdk";
+
export type BlueBubblesService = "imessage" | "sms" | "auto";
export type BlueBubblesTarget =
@@ -205,54 +213,30 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
}
const lower = trimmed.toLowerCase();
- for (const { prefix, service } of SERVICE_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const remainder = stripPrefix(trimmed, prefix);
- if (!remainder) {
- throw new Error(`${prefix} target is required`);
- }
- const remainderLower = remainder.toLowerCase();
- const isChatTarget =
- CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
- CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
- CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
- remainderLower.startsWith("group:");
- if (isChatTarget) {
- return parseBlueBubblesTarget(remainder);
- }
- return { kind: "handle", to: remainder, service };
- }
+ const servicePrefixed = resolveServicePrefixedTarget({
+ trimmed,
+ lower,
+ servicePrefixes: SERVICE_PREFIXES,
+ isChatTarget: (remainderLower) =>
+ CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
+ CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
+ CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
+ remainderLower.startsWith("group:"),
+ parseTarget: parseBlueBubblesTarget,
+ });
+ if (servicePrefixed) {
+ return servicePrefixed;
}
- for (const prefix of CHAT_ID_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- const chatId = Number.parseInt(value, 10);
- if (!Number.isFinite(chatId)) {
- throw new Error(`Invalid chat_id: ${value}`);
- }
- return { kind: "chat_id", chatId };
- }
- }
-
- for (const prefix of CHAT_GUID_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- if (!value) {
- throw new Error("chat_guid is required");
- }
- return { kind: "chat_guid", chatGuid: value };
- }
- }
-
- for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- if (!value) {
- throw new Error("chat_identifier is required");
- }
- return { kind: "chat_identifier", chatIdentifier: value };
- }
+ const chatTarget = parseChatTargetPrefixesOrThrow({
+ trimmed,
+ lower,
+ chatIdPrefixes: CHAT_ID_PREFIXES,
+ chatGuidPrefixes: CHAT_GUID_PREFIXES,
+ chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
+ });
+ if (chatTarget) {
+ return chatTarget;
}
if (lower.startsWith("group:")) {
@@ -293,42 +277,25 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
}
const lower = trimmed.toLowerCase();
- for (const { prefix } of SERVICE_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const remainder = stripPrefix(trimmed, prefix);
- if (!remainder) {
- return { kind: "handle", handle: "" };
- }
- return parseBlueBubblesAllowTarget(remainder);
- }
+ const servicePrefixed = resolveServicePrefixedAllowTarget({
+ trimmed,
+ lower,
+ servicePrefixes: SERVICE_PREFIXES,
+ parseAllowTarget: parseBlueBubblesAllowTarget,
+ });
+ if (servicePrefixed) {
+ return servicePrefixed;
}
- for (const prefix of CHAT_ID_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- const chatId = Number.parseInt(value, 10);
- if (Number.isFinite(chatId)) {
- return { kind: "chat_id", chatId };
- }
- }
- }
-
- for (const prefix of CHAT_GUID_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- if (value) {
- return { kind: "chat_guid", chatGuid: value };
- }
- }
- }
-
- for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
- if (lower.startsWith(prefix)) {
- const value = stripPrefix(trimmed, prefix);
- if (value) {
- return { kind: "chat_identifier", chatIdentifier: value };
- }
- }
+ const chatTarget = parseChatAllowTargetPrefixes({
+ trimmed,
+ lower,
+ chatIdPrefixes: CHAT_ID_PREFIXES,
+ chatGuidPrefixes: CHAT_GUID_PREFIXES,
+ chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
+ });
+ if (chatTarget) {
+ return chatTarget;
}
if (lower.startsWith("group:")) {
@@ -363,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: {
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
- const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
- if (allowFrom.length === 0) {
- return true;
- }
- if (allowFrom.includes("*")) {
- return true;
- }
-
- const senderNormalized = normalizeBlueBubblesHandle(params.sender);
- const chatId = params.chatId ?? undefined;
- const chatGuid = params.chatGuid?.trim();
- const chatIdentifier = params.chatIdentifier?.trim();
-
- for (const entry of allowFrom) {
- if (!entry) {
- continue;
- }
- const parsed = parseBlueBubblesAllowTarget(entry);
- if (parsed.kind === "chat_id" && chatId !== undefined) {
- if (parsed.chatId === chatId) {
- return true;
- }
- } else if (parsed.kind === "chat_guid" && chatGuid) {
- if (parsed.chatGuid === chatGuid) {
- return true;
- }
- } else if (parsed.kind === "chat_identifier" && chatIdentifier) {
- if (parsed.chatIdentifier === chatIdentifier) {
- return true;
- }
- } else if (parsed.kind === "handle" && senderNormalized) {
- if (parsed.handle === senderNormalized) {
- return true;
- }
- }
- }
- return false;
+ return isAllowedParsedChatSender({
+ allowFrom: params.allowFrom,
+ sender: params.sender,
+ chatId: params.chatId,
+ chatGuid: params.chatGuid,
+ chatIdentifier: params.chatIdentifier,
+ normalizeSender: normalizeBlueBubblesHandle,
+ parseAllowTarget: parseBlueBubblesAllowTarget,
+ });
}
export function formatBlueBubblesChatTarget(params: {
diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts
new file mode 100644
index 00000000000..627b04197ba
--- /dev/null
+++ b/extensions/bluebubbles/src/test-harness.ts
@@ -0,0 +1,50 @@
+import type { Mock } from "vitest";
+import { afterEach, beforeEach, vi } from "vitest";
+
+export function resolveBlueBubblesAccountFromConfig(params: {
+ cfg?: { channels?: { bluebubbles?: Record } };
+ accountId?: string;
+}) {
+ const config = params.cfg?.channels?.bluebubbles ?? {};
+ return {
+ accountId: params.accountId ?? "default",
+ enabled: config.enabled !== false,
+ configured: Boolean(config.serverUrl && config.password),
+ config,
+ };
+}
+
+export function createBlueBubblesAccountsMockModule() {
+ return {
+ resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
+ };
+}
+
+type BlueBubblesProbeMockModule = {
+ getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
+};
+
+export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
+ return {
+ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
+ };
+}
+
+export function installBlueBubblesFetchTestHooks(params: {
+ mockFetch: ReturnType;
+ privateApiStatusMock: {
+ mockReset: () => unknown;
+ mockReturnValue: (value: boolean | null) => unknown;
+ };
+}) {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", params.mockFetch);
+ params.mockFetch.mockReset();
+ params.privateApiStatusMock.mockReset();
+ params.privateApiStatusMock.mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+}
diff --git a/extensions/bluebubbles/src/test-mocks.ts b/extensions/bluebubbles/src/test-mocks.ts
new file mode 100644
index 00000000000..d0a4801663d
--- /dev/null
+++ b/extensions/bluebubbles/src/test-mocks.ts
@@ -0,0 +1,11 @@
+import { vi } from "vitest";
+
+vi.mock("./accounts.js", async () => {
+ const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
+ return createBlueBubblesAccountsMockModule();
+});
+
+vi.mock("./probe.js", async () => {
+ const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
+ return createBlueBubblesProbeMockModule();
+});
diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts
index 24c82109cdf..7346c4ff42a 100644
--- a/extensions/bluebubbles/src/types.ts
+++ b/extensions/bluebubbles/src/types.ts
@@ -1,5 +1,6 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
-export type { DmPolicy, GroupPolicy };
+
+export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */
@@ -45,6 +46,11 @@ export type BlueBubblesAccountConfig = {
blockStreamingCoalesce?: Record;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
+ /**
+ * Explicit allowlist of local directory roots permitted for outbound media paths.
+ * Local paths are rejected unless they resolve under one of these roots.
+ */
+ mediaLocalRoots?: string[];
/** Send read receipts for incoming messages (default: true). */
sendReadReceipts?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index fea015da4dd..756b6a26849 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
- "version": "2026.2.13",
+ "version": "2026.2.16",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts
index 3f9049fdc4d..7af30d6135c 100644
--- a/extensions/device-pair/index.ts
+++ b/extensions/device-pair/index.ts
@@ -1,6 +1,15 @@
-import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import os from "node:os";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
+import qrcode from "qrcode-terminal";
+
+function renderQrAscii(data: string): Promise {
+ return new Promise((resolve) => {
+ qrcode.generate(data, { small: true }, (output: string) => {
+ resolve(output);
+ });
+ });
+}
const DEFAULT_GATEWAY_PORT = 18789;
@@ -120,7 +129,7 @@ function isTailnetIPv4(address: string): boolean {
return a === 100 && b >= 64 && b <= 127;
}
-function pickLanIPv4(): string | null {
+function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
@@ -137,7 +146,7 @@ function pickLanIPv4(): string | null {
if (!address) {
continue;
}
- if (isPrivateIPv4(address)) {
+ if (predicate(address)) {
return address;
}
}
@@ -145,29 +154,12 @@ function pickLanIPv4(): string | null {
return null;
}
+function pickLanIPv4(): string | null {
+ return pickMatchingIPv4(isPrivateIPv4);
+}
+
function pickTailnetIPv4(): string | null {
- const nets = os.networkInterfaces();
- for (const entries of Object.values(nets)) {
- if (!entries) {
- continue;
- }
- for (const entry of entries) {
- const family = entry?.family;
- // Check for IPv4 (string "IPv4" on Node 18+, number 4 on older)
- const isIpv4 = family === "IPv4" || String(family) === "4";
- if (!entry || entry.internal || !isIpv4) {
- continue;
- }
- const address = entry.address?.trim() ?? "";
- if (!address) {
- continue;
- }
- if (isTailnetIPv4(address)) {
- return address;
- }
- }
- }
- return null;
+ return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(api: OpenClawPluginApi): Promise {
@@ -451,6 +443,69 @@ export default function register(api: OpenClawPluginApi) {
password: auth.password,
};
+ if (action === "qr") {
+ const setupCode = encodeSetupCode(payload);
+ const qrAscii = await renderQrAscii(setupCode);
+ const authLabel = auth.label ?? "auth";
+
+ const channel = ctx.channel;
+ const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
+
+ if (channel === "telegram" && target) {
+ try {
+ const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
+ if (send) {
+ await send(
+ target,
+ ["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
+ "\n",
+ ),
+ {
+ ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
+ ...(ctx.accountId ? { accountId: ctx.accountId } : {}),
+ },
+ );
+ return {
+ text: [
+ `Gateway: ${payload.url}`,
+ `Auth: ${authLabel}`,
+ "",
+ "After scanning, come back here and run `/pair approve` to complete pairing.",
+ ].join("\n"),
+ };
+ }
+ } catch (err) {
+ api.logger.warn?.(
+ `device-pair: telegram QR send failed, falling back (${String(
+ (err as Error)?.message ?? err,
+ )})`,
+ );
+ }
+ }
+
+ // Render based on channel capability
+ api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
+ const infoLines = [
+ `Gateway: ${payload.url}`,
+ `Auth: ${authLabel}`,
+ "",
+ "After scanning, run `/pair approve` to complete pairing.",
+ ];
+
+ // WebUI + CLI/TUI: ASCII QR
+ return {
+ text: [
+ "Scan this QR code with the OpenClaw iOS app:",
+ "",
+ "```",
+ qrAscii,
+ "```",
+ "",
+ ...infoLines,
+ ].join("\n"),
+ };
+ }
+
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index 81a69698186..c0098b1a14b 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
- "version": "2026.2.13",
+ "version": "2026.2.16",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts
index c379dc7a9fc..ea32fc3ea5f 100644
--- a/extensions/diagnostics-otel/src/service.test.ts
+++ b/extensions/diagnostics-otel/src/service.test.ts
@@ -105,6 +105,7 @@ vi.mock("openclaw/plugin-sdk", async () => {
});
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
+import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { createDiagnosticsOtelService } from "./service.js";
describe("diagnostics-otel service", () => {
@@ -130,7 +131,7 @@ describe("diagnostics-otel service", () => {
});
const service = createDiagnosticsOtelService();
- await service.start({
+ const ctx: OpenClawPluginServiceContext = {
config: {
diagnostics: {
enabled: true,
@@ -150,7 +151,9 @@ describe("diagnostics-otel service", () => {
error: vi.fn(),
debug: vi.fn(),
},
- });
+ stateDir: "/tmp/openclaw-diagnostics-otel-test",
+ };
+ await service.start(ctx);
emitDiagnosticEvent({
type: "webhook.received",
@@ -222,6 +225,6 @@ describe("diagnostics-otel service", () => {
});
expect(logEmit).toHaveBeenCalled();
- await service.stop?.();
+ await service.stop?.(ctx);
});
});
diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts
index 5b747f13cdb..101812b2e32 100644
--- a/extensions/diagnostics-otel/src/service.ts
+++ b/extensions/diagnostics-otel/src/service.ts
@@ -1,6 +1,5 @@
-import type { SeverityNumber } from "@opentelemetry/api-logs";
-import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
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";
@@ -10,6 +9,7 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
+import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
const DEFAULT_SERVICE_NAME = "openclaw";
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index 7018238f145..b68e1223337 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
- "version": "2026.2.13",
+ "version": "2026.2.16",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {
diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts
index 5d9e101f579..4db082e32ef 100644
--- a/extensions/discord/src/channel.ts
+++ b/extensions/discord/src/channel.ts
@@ -16,6 +16,7 @@ import {
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeDiscordMessagingTarget,
+ normalizeDiscordOutboundTarget,
PAIRING_APPROVED_MESSAGE,
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
@@ -158,6 +159,12 @@ export const discordPlugin: ChannelPlugin = {
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
},
+ agentPrompt: {
+ messageToolHints: () => [
+ "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
+ "- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
+ ],
+ },
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
targetResolver: {
@@ -285,28 +292,32 @@ export const discordPlugin: ChannelPlugin = {
chunker: null,
textChunkLimit: 2000,
pollMaxOptions: 10,
- sendText: async ({ to, text, accountId, deps, replyToId }) => {
+ resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
+ sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
+ silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
- sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
+ sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
mediaUrl,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
+ silent: silent ?? undefined,
});
return { channel: "discord", ...result };
},
- sendPoll: async ({ to, poll, accountId }) =>
+ sendPoll: async ({ to, poll, accountId, silent }) =>
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
accountId: accountId ?? undefined,
+ silent: silent ?? undefined,
}),
},
status: {
diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json
index 72e49b72f69..c5ae74770da 100644
--- a/extensions/feishu/package.json
+++ b/extensions/feishu/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
- "version": "2026.2.13",
+ "version": "2026.2.16",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
@@ -8,6 +8,9 @@
"@sinclair/typebox": "0.34.48",
"zod": "^4.3.6"
},
+ "devDependencies": {
+ "openclaw": "workspace:*"
+ },
"openclaw": {
"extensions": [
"./index.ts"
diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts
index 4464a1597b4..ef61b7959b8 100644
--- a/extensions/feishu/src/accounts.ts
+++ b/extensions/feishu/src/accounts.ts
@@ -1,5 +1,6 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
-import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
+import { createAccountListHelpers } from "openclaw/plugin-sdk";
+import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
FeishuConfig,
FeishuAccountConfig,
@@ -7,40 +8,9 @@ import type {
ResolvedFeishuAccount,
} from "./types.js";
-/**
- * List all configured account IDs from the accounts field.
- */
-function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
- const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
- if (!accounts || typeof accounts !== "object") {
- return [];
- }
- return Object.keys(accounts).filter(Boolean);
-}
-
-/**
- * List all Feishu account IDs.
- * If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
- */
-export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
- const ids = listConfiguredAccountIds(cfg);
- if (ids.length === 0) {
- // Backward compatibility: no accounts configured, use default
- return [DEFAULT_ACCOUNT_ID];
- }
- return [...ids].toSorted((a, b) => a.localeCompare(b));
-}
-
-/**
- * Resolve the default account ID.
- */
-export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
- const ids = listFeishuAccountIds(cfg);
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
- return DEFAULT_ACCOUNT_ID;
- }
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
-}
+const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("feishu");
+export const listFeishuAccountIds = listAccountIds;
+export const resolveDefaultFeishuAccountId = resolveDefaultAccountId;
/**
* Get the raw account-specific config.
diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts
index 3ea22fbf4a8..3fe46409766 100644
--- a/extensions/feishu/src/bitable.ts
+++ b/extensions/feishu/src/bitable.ts
@@ -1,7 +1,7 @@
-import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { Type } from "@sinclair/typebox";
-import type { FeishuConfig } from "./types.js";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { createFeishuClient } from "./client.js";
+import type { FeishuConfig } from "./types.js";
// ============ Helpers ============
@@ -224,6 +224,198 @@ async function createRecord(
};
}
+/** Logger interface for cleanup operations */
+type CleanupLogger = {
+ debug: (msg: string) => void;
+ warn: (msg: string) => void;
+};
+
+/** Default field types created for new Bitable tables (to be cleaned up) */
+const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
+
+/** Clean up default placeholder rows and fields in a newly created Bitable table */
+async function cleanupNewBitable(
+ client: ReturnType,
+ appToken: string,
+ tableId: string,
+ tableName: string,
+ logger: CleanupLogger,
+): Promise<{ cleanedRows: number; cleanedFields: number }> {
+ let cleanedRows = 0;
+ let cleanedFields = 0;
+
+ // Step 1: Clean up default fields
+ const fieldsRes = await client.bitable.appTableField.list({
+ path: { app_token: appToken, table_id: tableId },
+ });
+
+ if (fieldsRes.code === 0 && fieldsRes.data?.items) {
+ // Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
+ const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
+ if (primaryField?.field_id) {
+ try {
+ const newFieldName = tableName.length <= 20 ? tableName : "Name";
+ await client.bitable.appTableField.update({
+ path: {
+ app_token: appToken,
+ table_id: tableId,
+ field_id: primaryField.field_id,
+ },
+ data: {
+ field_name: newFieldName,
+ type: 1,
+ },
+ });
+ cleanedFields++;
+ } catch (err) {
+ logger.debug(`Failed to rename primary field: ${err}`);
+ }
+ }
+
+ // Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
+ const defaultFieldsToDelete = fieldsRes.data.items.filter(
+ (f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
+ );
+
+ for (const field of defaultFieldsToDelete) {
+ if (field.field_id) {
+ try {
+ await client.bitable.appTableField.delete({
+ path: {
+ app_token: appToken,
+ table_id: tableId,
+ field_id: field.field_id,
+ },
+ });
+ cleanedFields++;
+ } catch (err) {
+ logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
+ }
+ }
+ }
+ }
+
+ // Step 2: Delete empty placeholder rows (batch when possible)
+ const recordsRes = await client.bitable.appTableRecord.list({
+ path: { app_token: appToken, table_id: tableId },
+ params: { page_size: 100 },
+ });
+
+ if (recordsRes.code === 0 && recordsRes.data?.items) {
+ const emptyRecordIds = recordsRes.data.items
+ .filter((r) => !r.fields || Object.keys(r.fields).length === 0)
+ .map((r) => r.record_id)
+ .filter((id): id is string => Boolean(id));
+
+ if (emptyRecordIds.length > 0) {
+ try {
+ await client.bitable.appTableRecord.batchDelete({
+ path: { app_token: appToken, table_id: tableId },
+ data: { records: emptyRecordIds },
+ });
+ cleanedRows = emptyRecordIds.length;
+ } catch {
+ // Fallback: delete one by one if batch API is unavailable
+ for (const recordId of emptyRecordIds) {
+ try {
+ await client.bitable.appTableRecord.delete({
+ path: { app_token: appToken, table_id: tableId, record_id: recordId },
+ });
+ cleanedRows++;
+ } catch (err) {
+ logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
+ }
+ }
+ }
+ }
+ }
+
+ return { cleanedRows, cleanedFields };
+}
+
+async function createApp(
+ client: ReturnType,
+ name: string,
+ folderToken?: string,
+ logger?: CleanupLogger,
+) {
+ const res = await client.bitable.app.create({
+ data: {
+ name,
+ ...(folderToken && { folder_token: folderToken }),
+ },
+ });
+ if (res.code !== 0) {
+ throw new Error(res.msg);
+ }
+
+ const appToken = res.data?.app?.app_token;
+ if (!appToken) {
+ throw new Error("Failed to create Bitable: no app_token returned");
+ }
+
+ const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
+ let tableId: string | undefined;
+ let cleanedRows = 0;
+ let cleanedFields = 0;
+
+ try {
+ const tablesRes = await client.bitable.appTable.list({
+ path: { app_token: appToken },
+ });
+ if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
+ tableId = tablesRes.data.items[0].table_id ?? undefined;
+ if (tableId) {
+ const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
+ cleanedRows = cleanup.cleanedRows;
+ cleanedFields = cleanup.cleanedFields;
+ }
+ }
+ } catch (err) {
+ log.debug(`Cleanup failed (non-critical): ${err}`);
+ }
+
+ return {
+ app_token: appToken,
+ table_id: tableId,
+ name: res.data?.app?.name,
+ url: res.data?.app?.url,
+ cleaned_placeholder_rows: cleanedRows,
+ cleaned_default_fields: cleanedFields,
+ hint: tableId
+ ? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
+ : "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
+ };
+}
+
+async function createField(
+ client: ReturnType,
+ appToken: string,
+ tableId: string,
+ fieldName: string,
+ fieldType: number,
+ property?: Record