Compare commits
213 Commits
pr15280-me
...
feat/routi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7b789d85 | ||
|
|
054366dea4 | ||
|
|
d714ac7797 | ||
|
|
3e6d1e9cf8 | ||
|
|
478af81706 | ||
|
|
ff32f43459 | ||
|
|
81b5e2766b | ||
|
|
078642b308 | ||
|
|
69f809dca3 | ||
|
|
9236a27456 | ||
|
|
fe2d883cf7 | ||
|
|
5349a0f7c2 | ||
|
|
8ff2787981 | ||
|
|
94ff44f112 | ||
|
|
ebcc6480c2 | ||
|
|
f2c56de955 | ||
|
|
a7142c6218 | ||
|
|
ee82c173ae | ||
|
|
2b5e0a6075 | ||
|
|
76e4e9d176 | ||
|
|
684c18458a | ||
|
|
9fb48f4dff | ||
|
|
ebc68861a6 | ||
|
|
d3428053d9 | ||
|
|
188c4cd076 | ||
|
|
b908388245 | ||
|
|
66d7178f2d | ||
|
|
d583782ee3 | ||
|
|
61d59a8028 | ||
|
|
9dce3d8bf8 | ||
|
|
1d6abddb9f | ||
|
|
226bf74634 | ||
|
|
3e0e78f82a | ||
|
|
eb60e2e1b2 | ||
|
|
9e147f00b4 | ||
|
|
50645b905b | ||
|
|
6084d13b95 | ||
|
|
5b4121d601 | ||
|
|
d82c5ea9d1 | ||
|
|
8d1a1d9e86 | ||
|
|
64df787448 | ||
|
|
cc233da373 | ||
|
|
e9de242159 | ||
|
|
bc4881ed0c | ||
|
|
cdc31903c2 | ||
|
|
d1f36bfd84 | ||
|
|
4caeb203a6 | ||
|
|
e1e05e57cb | ||
|
|
8218a94a31 | ||
|
|
e401e2584d | ||
|
|
0dbe087ef8 | ||
|
|
4734c985c8 | ||
|
|
270779b2cd | ||
|
|
7bd073340a | ||
|
|
4f61a3f527 | ||
|
|
3e2f0ca077 | ||
|
|
747b11c83e | ||
|
|
268c14f021 | ||
|
|
1a4fb35030 | ||
|
|
2004ce919a | ||
|
|
3150ece95a | ||
|
|
f97ad8f288 | ||
|
|
4c74a2f06e | ||
|
|
9f84afc992 | ||
|
|
a1fc6a6ea6 | ||
|
|
1b9c1c648d | ||
|
|
ece55b4682 | ||
|
|
1b03eb71aa | ||
|
|
bc0160d0f2 | ||
|
|
06bc9f368b | ||
|
|
81361755b7 | ||
|
|
b769b65b48 | ||
|
|
d71f6afb7f | ||
|
|
25ecd4216c | ||
|
|
b3882eccef | ||
|
|
7fc1026746 | ||
|
|
e707a7bd36 | ||
|
|
60a7625f2a | ||
|
|
fdc3a6a809 | ||
|
|
50a6e0e69e | ||
|
|
aa1dbd34a1 | ||
|
|
3881af5b37 | ||
|
|
e3b432e481 | ||
|
|
09e1cbc35d | ||
|
|
497b060e49 | ||
|
|
a6fbd0393d | ||
|
|
abf6b4997e | ||
|
|
b87b16e2b6 | ||
|
|
b566b09f81 | ||
|
|
1f1fc095a0 | ||
|
|
31791233d6 | ||
|
|
4f043991e0 | ||
|
|
4c7838e3cf | ||
|
|
5f4b29145c | ||
|
|
d3ee5deb87 | ||
|
|
c8424bf29a | ||
|
|
3967ece625 | ||
|
|
cb9a5e1cb9 | ||
|
|
302dafbe1a | ||
|
|
493f6f458b | ||
|
|
57f40a5da6 | ||
|
|
788ea6e9d1 | ||
|
|
1a7e180e68 | ||
|
|
00a0890889 | ||
|
|
4b1cadaecb | ||
|
|
e53a221e5c | ||
|
|
28d9dd7a77 | ||
|
|
644bef157a | ||
|
|
35c0e66ed0 | ||
|
|
3d0a41b584 | ||
|
|
3a67721dae | ||
|
|
6a386a7886 | ||
|
|
8025e7c6c2 | ||
|
|
842499d6c5 | ||
|
|
3aa94afcfd | ||
|
|
9a134c8a10 | ||
|
|
ce0eddd384 | ||
|
|
7d3e5788e8 | ||
|
|
74193ff754 | ||
|
|
c76288bdf1 | ||
|
|
ef70a55b7a | ||
|
|
6f7d31c426 | ||
|
|
d69b32a073 | ||
|
|
d73b48b32c | ||
|
|
ec399aaddf | ||
|
|
18e8bd68c5 | ||
|
|
3bbd29bef9 | ||
|
|
a0361b8ba9 | ||
|
|
6543ce717c | ||
|
|
1ba266a8e8 | ||
|
|
bf080c2338 | ||
|
|
274da72c38 | ||
|
|
83248f7603 | ||
|
|
af50b914a4 | ||
|
|
a2b45e1c13 | ||
|
|
7b39543e8d | ||
|
|
0af76f5f0e | ||
|
|
c15946274e | ||
|
|
a7af646fdf | ||
|
|
318379cdba | ||
|
|
233483d2b9 | ||
|
|
0cfea46293 | ||
|
|
9bb099736b | ||
|
|
cd84885a4a | ||
|
|
586176730c | ||
|
|
c90b3e4d5e | ||
|
|
a7a08b6650 | ||
|
|
153a7644ea | ||
|
|
6dd6bce997 | ||
|
|
eb4215d570 | ||
|
|
626a225c08 | ||
|
|
f8ba8f7699 | ||
|
|
01d2ad2050 | ||
|
|
79e78cff3b | ||
|
|
4711a943e3 | ||
|
|
bb1c3dfe10 | ||
|
|
9e24eee52c | ||
|
|
539689a2f2 | ||
|
|
fba19fe942 | ||
|
|
3b56a6252b | ||
|
|
e21a7aad54 | ||
|
|
1fb52b4d7b | ||
|
|
3a330e681b | ||
|
|
6182d3ef85 | ||
|
|
9475791d98 | ||
|
|
c17a109daa | ||
|
|
ad96c126ed | ||
|
|
4c79a63eb8 | ||
|
|
e38ed4f640 | ||
|
|
a50638eead | ||
|
|
0e5e72edb4 | ||
|
|
98bb4225fd | ||
|
|
db72184de6 | ||
|
|
45e12d2388 | ||
|
|
d8beddc8b7 | ||
|
|
2f4cef2021 | ||
|
|
4335668d28 | ||
|
|
e6d5b5fb11 | ||
|
|
1f432ffb93 | ||
|
|
eab9dc538a | ||
|
|
fdda261478 | ||
|
|
e0132514f6 | ||
|
|
3feb5d1f10 | ||
|
|
f90a39e984 | ||
|
|
ae8be6ac23 | ||
|
|
8f2884b986 | ||
|
|
c640b5f86c | ||
|
|
84ed9ab554 | ||
|
|
d1f01de59a | ||
|
|
e91d957d70 | ||
|
|
38a157ff23 | ||
|
|
2d4d32cb2d | ||
|
|
89fa93ed75 | ||
|
|
7f227fc8cc | ||
|
|
115444b37c | ||
|
|
9126930363 | ||
|
|
72e9364bac | ||
|
|
dd08ca97bb | ||
|
|
2583de5305 | ||
|
|
89574f30cb | ||
|
|
edbd86074f | ||
|
|
36726b52f4 | ||
|
|
3871b5a238 | ||
|
|
63711330e4 | ||
|
|
d3eb014892 | ||
|
|
203b5bdf71 | ||
|
|
6ebf503fa8 | ||
|
|
03fee3c605 | ||
|
|
61b5133264 | ||
|
|
5219f74615 | ||
|
|
2b154e0458 | ||
|
|
9443c638f4 | ||
|
|
13aface863 |
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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).
|
||||
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -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.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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.
|
||||
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -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.<channel>.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.
|
||||
108
.github/pull_request_template.md
vendored
Normal file
108
.github/pull_request_template.md
vendored
Normal file
@@ -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:
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -82,4 +82,5 @@ USER.md
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
local/
|
||||
/local/
|
||||
package-lock.json
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; 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
|
||||
|
||||
|
||||
202
CHANGELOG.md
202
CHANGELOG.md
@@ -2,92 +2,162 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.13 (Unreleased)
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
- 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 pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale.
|
||||
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
|
||||
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- 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.
|
||||
- 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)
|
||||
- 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/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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
|
||||
- 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/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk.
|
||||
- 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/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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/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.
|
||||
- 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.
|
||||
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
|
||||
- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
|
||||
- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058)
|
||||
- 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.
|
||||
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
||||
- 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.
|
||||
- Security/Google Chat: deprecate `users/<email>` 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.
|
||||
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
|
||||
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
|
||||
- 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/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/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
|
||||
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
|
||||
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
|
||||
- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.2.14
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
|
||||
- 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.
|
||||
- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
|
||||
- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. 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).
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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/<guildId>` 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
|
||||
- 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.
|
||||
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
|
||||
- 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.
|
||||
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
|
||||
@@ -53,7 +53,14 @@ For threat model + hardening guidance (including `openclaw security audit --deep
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
151
appcast.xml
151
appcast.xml
@@ -2,6 +2,107 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.13</title>
|
||||
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9846</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.13</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.13</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.</li>
|
||||
<li>Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.</li>
|
||||
<li>Slack/Plugins: add thread-ownership outbound gating via <code>message_sending</code> hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.</li>
|
||||
<li>Agents: add synthetic catalog support for <code>hf:zai-org/GLM-5</code>. (#15867) Thanks @battman21.</li>
|
||||
<li>Skills: remove duplicate <code>local-places</code> Google Places skill/proxy and keep <code>goplaces</code> as the single supported Google Places path.</li>
|
||||
<li>Agents: add pre-prompt context diagnostics (<code>messages</code>, <code>systemPromptChars</code>, <code>promptChars</code>, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.</li>
|
||||
<li>Auto-reply/Threading: auto-inject implicit reply threading so <code>replyToMode</code> works without requiring model-emitted <code>[[reply_to_current]]</code>, while preserving <code>replyToMode: "off"</code> behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under <code>replyToMode: "first"</code>. (#14976) Thanks @Diaspar4u.</li>
|
||||
<li>Outbound/Threading: pass <code>replyTo</code> and <code>threadId</code> from <code>message send</code> tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.</li>
|
||||
<li>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.</li>
|
||||
<li>Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.</li>
|
||||
<li>Web UI: add <code>img</code> to DOMPurify allowed tags and <code>src</code>/<code>alt</code> to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.</li>
|
||||
<li>Telegram/Matrix: treat MP3 and M4A (including <code>audio/mp4</code>) as voice-compatible for <code>asVoice</code> routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.</li>
|
||||
<li>WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending <code>"file"</code>. (#15594) Thanks @TsekaLuk.</li>
|
||||
<li>Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.</li>
|
||||
<li>Telegram: scope skill commands to the resolved agent for default accounts so <code>setMyCommands</code> no longer triggers <code>BOT_COMMANDS_TOO_MUCH</code> when multiple agents are configured. (#15599)</li>
|
||||
<li>Discord: avoid misrouting numeric guild allowlist entries to <code>/channels/<guildId></code> by prefixing guild-only inputs with <code>guild:</code> during resolution. (#12326) Thanks @headswim.</li>
|
||||
<li>MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (<code>29:...</code>, <code>8:orgid:...</code>) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.</li>
|
||||
<li>Media: classify <code>text/*</code> MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.</li>
|
||||
<li>Inbound/Web UI: preserve literal <code>\n</code> sequences when normalizing inbound text so Windows paths like <code>C:\\Work\\nxxx\\README.md</code> are not corrupted. (#11547) Thanks @mcaxtr.</li>
|
||||
<li>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.</li>
|
||||
<li>Providers/MiniMax: switch implicit MiniMax API-key provider from <code>openai-completions</code> to <code>anthropic-messages</code> with the correct Anthropic-compatible base URL, fixing <code>invalid role: developer (2013)</code> errors on MiniMax M2.5. (#15275) Thanks @lailoo.</li>
|
||||
<li>Ollama/Agents: use resolved model/provider base URLs for native <code>/api/chat</code> streaming (including aliased providers), normalize <code>/v1</code> endpoints, and forward abort + <code>maxTokens</code> stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.</li>
|
||||
<li>OpenAI Codex/Spark: implement end-to-end <code>gpt-5.3-codex-spark</code> support across fallback/thinking/model resolution and <code>models list</code> forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.</li>
|
||||
<li>Agents/Codex: allow <code>gpt-5.3-codex-spark</code> in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.</li>
|
||||
<li>Models/Codex: resolve configured <code>openai-codex/gpt-5.3-codex-spark</code> through forward-compat fallback during <code>models list</code>, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.</li>
|
||||
<li>OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into <code>pi</code> <code>auth.json</code> so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.</li>
|
||||
<li>Auth/OpenAI Codex: share OAuth login handling across onboarding and <code>models auth login --provider openai-codex</code>, 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.</li>
|
||||
<li>Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.</li>
|
||||
<li>Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (<code>tokenProvider=huggingface</code> with <code>authChoice=apiKey</code>) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.</li>
|
||||
<li>Onboarding/CLI: restore terminal state without resuming paused <code>stdin</code>, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.</li>
|
||||
<li>Signal/Install: auto-install <code>signal-cli</code> via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary <code>Exec format error</code> failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Discord/Agents: apply channel/group <code>historyLimit</code> during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Heartbeat: allow explicit wake (<code>wake</code>) and hook wake (<code>hook:*</code>) reasons to run even when <code>HEARTBEAT.md</code> is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.</li>
|
||||
<li>Auto-reply/Heartbeat: strip sentence-ending <code>HEARTBEAT_OK</code> tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.</li>
|
||||
<li>Agents/Heartbeat: stop auto-creating <code>HEARTBEAT.md</code> during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with <code>Session file path must be within sessions directory</code>. (#15141) Thanks @Goldenmonstew.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> 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.</li>
|
||||
<li>Sessions: archive previous transcript files on <code>/new</code> and <code>/reset</code> session resets (including gateway <code>sessions.reset</code>) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.</li>
|
||||
<li>Status/Sessions: stop clamping derived <code>totalTokens</code> 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.</li>
|
||||
<li>CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid <code>source <(openclaw completion ...)</code> corruption. (#15481) Thanks @arosstale.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/Gateway + ACP: block high-risk tools (<code>sessions_spawn</code>, <code>sessions_send</code>, <code>gateway</code>, <code>whatsapp_login</code>) from HTTP <code>/tools/invoke</code> by default with <code>gateway.tools.{allow,deny}</code> overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting <code>allow_always</code>/<code>reject_always</code>. (#15390) Thanks @aether-ai-agent.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/Browser: constrain <code>POST /trace/stop</code>, <code>POST /wait/download</code>, and <code>POST /download</code> output paths to OpenClaw temp roots and reject traversal/escape paths.</li>
|
||||
<li>Security/Canvas: serve A2UI assets via the shared safe-open path (<code>openFileWithinRoot</code>) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.</li>
|
||||
<li>Security/WhatsApp: enforce <code>0o600</code> on <code>creds.json</code> and <code>creds.json.bak</code> on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.</li>
|
||||
<li>Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective <code>gateway.nodes.denyCommands</code> entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.</li>
|
||||
<li>Security/Audit: distinguish external webhooks (<code>hooks.enabled</code>) from internal hooks (<code>hooks.internal.enabled</code>) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.</li>
|
||||
<li>Security/Onboarding: clarify multi-user DM isolation remediation with explicit <code>openclaw config set session.dmScope ...</code> commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.</li>
|
||||
<li>Agents/Nodes: harden node exec approval decision handling in the <code>nodes</code> tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.</li>
|
||||
<li>Android/Nodes: harden <code>app.update</code> 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.</li>
|
||||
<li>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.</li>
|
||||
<li>Exec/Allowlist: allow multiline heredoc bodies (<code><<</code>, <code><<-</code>) 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.</li>
|
||||
<li>Config: preserve <code>${VAR}</code> env references when writing config files so <code>openclaw config set/apply/patch</code> does not persist secrets to disk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving <code>${VAR}</code> refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.</li>
|
||||
<li>Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.</li>
|
||||
<li>Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.</li>
|
||||
<li>Config: accept <code>$schema</code> key in config file so JSON Schema editor tooling works without validation errors. (#14998)</li>
|
||||
<li>Gateway/Tools Invoke: sanitize <code>/tools/invoke</code> execution failures while preserving <code>400</code> for tool input errors and returning <code>500</code> for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.</li>
|
||||
<li>Gateway/Hooks: preserve <code>408</code> for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.</li>
|
||||
<li>Plugins/Hooks: fire <code>before_tool_call</code> hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/Image tool: cap image-analysis completion <code>maxTokens</code> by model capability (<code>min(4096, model.maxTokens)</code>) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.</li>
|
||||
<li>Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent <code>tools.exec</code> overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.</li>
|
||||
<li>Gateway/Agents: stop injecting a phantom <code>main</code> agent into gateway agent listings when <code>agents.list</code> explicitly excludes it. (#11450) Thanks @arosstale.</li>
|
||||
<li>Process/Exec: avoid shell execution for <code>.exe</code> commands on Windows so env overrides work reliably in <code>runCommandWithTimeout</code>. Thanks @thewilloftheshadow.</li>
|
||||
<li>Daemon/Windows: preserve literal backslashes in <code>gateway.cmd</code> command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.</li>
|
||||
<li>Sandbox: pass configured <code>sandbox.docker.env</code> variables to sandbox containers at <code>docker create</code> time. (#15138) Thanks @stevebot-alive.</li>
|
||||
<li>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.</li>
|
||||
<li>Cron: add regression coverage for announce-mode isolated jobs so runs that already report <code>delivered: true</code> do not enqueue duplicate main-session relays, including delivery configs where <code>mode</code> is omitted and defaults to announce. (#15737) Thanks @brandonwise.</li>
|
||||
<li>Cron: honor <code>deleteAfterRun</code> 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.</li>
|
||||
<li>Web tools/web_fetch: prefer <code>text/markdown</code> responses for Cloudflare Markdown for Agents, add <code>cf-markdown</code> extraction for markdown bodies, and redact fetched URLs in <code>x-markdown-tokens</code> debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.</li>
|
||||
<li>Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.</li>
|
||||
<li>Memory: switch default local embedding model to the QAT <code>embeddinggemma-300m-qat-Q8_0</code> variant for better quality at the same footprint. (#15429) Thanks @azade-c.</li>
|
||||
<li>Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.12</title>
|
||||
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
|
||||
@@ -154,55 +255,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8900</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.3</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: remove last <code>@ts-nocheck</code> from <code>bot-handlers.ts</code>, use Grammy types directly, deduplicate <code>StickerMetadata</code>. Zero <code>@ts-nocheck</code> remaining in <code>src/telegram/</code>. (#9206)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot-message.ts</code>, type deps via <code>Omit<BuildTelegramMessageContextParams></code>, widen <code>allMedia</code> to <code>TelegramMediaRef[]</code>. (#9180)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot.ts</code>, fix duplicate <code>bot.catch</code> error handler (Grammy overrides), remove dead reaction <code>message_thread_id</code> routing, harden sticker cache guard. (#9077)</li>
|
||||
<li>Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.</li>
|
||||
<li>Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.</li>
|
||||
<li>Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.</li>
|
||||
<li>Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.</li>
|
||||
<li>Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.</li>
|
||||
<li>Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.</li>
|
||||
<li>Cron: default isolated jobs to announce delivery; accept ISO 8601 <code>schedule.at</code> in tool inputs.</li>
|
||||
<li>Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and <code>atMs</code> inputs.</li>
|
||||
<li>Cron: delete one-shot jobs after success by default; add <code>--keep-after-run</code> for CLI.</li>
|
||||
<li>Cron: suppress messaging tools during announce delivery so summaries post consistently.</li>
|
||||
<li>Cron: avoid duplicate deliveries when isolated runs send messages directly.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.</li>
|
||||
<li>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.</li>
|
||||
<li>Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.</li>
|
||||
<li>Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.</li>
|
||||
<li>Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.</li>
|
||||
<li>Web UI: resolve header logo path when <code>gateway.controlUi.basePath</code> is set. (#7178) Thanks @Yeom-JinHo.</li>
|
||||
<li>Web UI: apply button styling to the new-messages indicator.</li>
|
||||
<li>Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.</li>
|
||||
<li>Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.</li>
|
||||
<li>Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.</li>
|
||||
<li>Security: gate <code>whatsapp_login</code> tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.</li>
|
||||
<li>Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.</li>
|
||||
<li>Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.</li>
|
||||
<li>Cron: accept epoch timestamps and 0ms durations in CLI <code>--at</code> parsing.</li>
|
||||
<li>Cron: reload store data when the store file is recreated or mtime changes.</li>
|
||||
<li>Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.</li>
|
||||
<li>Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.</li>
|
||||
<li>macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = 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)
|
||||
}
|
||||
|
||||
@@ -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<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _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()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
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 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 +66,72 @@ 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")
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = 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?)
|
||||
|
||||
@@ -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<String> =
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -57,27 +73,57 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
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 }
|
||||
|
||||
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 }
|
||||
|
||||
if tlsRequired, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
||||
else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
@@ -92,19 +138,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 +178,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 +311,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 +347,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 +422,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 +459,35 @@ 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()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
@@ -662,5 +771,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()
|
||||
}
|
||||
}
|
||||
|
||||
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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"
|
||||
@@ -114,25 +115,73 @@ 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 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 loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
|
||||
42
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
42
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayTrustPromptAlert: ViewModifier {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
|
||||
Binding(
|
||||
get: { self.gatewayController.pendingTrustPrompt },
|
||||
set: { newValue in
|
||||
if newValue == nil {
|
||||
self.gatewayController.clearPendingTrustPrompt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct GatewayOnboardingView: View {
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ struct RootCanvas: View {
|
||||
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
|
||||
@@ -376,6 +376,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -388,11 +389,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)
|
||||
|
||||
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,43 @@ import Security
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
|
||||
|
||||
enum DeepLinkAgentPolicy {
|
||||
static let maxMessageChars = 20_000
|
||||
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):
|
||||
return "Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result<Void, ValidationError> {
|
||||
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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -683,7 +683,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? ""
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public final class GatewayDiscoveryModel {
|
||||
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||
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 +34,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 +46,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 +69,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<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
|
||||
@@ -133,9 +140,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 +161,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 +204,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 +218,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 +236,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,
|
||||
@@ -421,16 +435,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 +452,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 +464,7 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
self.pendingTXTResolvers[stableID] = resolver
|
||||
self.pendingServiceResolvers[stableID] = resolver
|
||||
resolver.start()
|
||||
}
|
||||
|
||||
@@ -607,9 +621,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<ResolvedGatewayService, Error>) -> Void
|
||||
private let logger: Logger
|
||||
private var didFinish = false
|
||||
|
||||
@@ -618,7 +638,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
||||
type: String,
|
||||
domain: String,
|
||||
logger: Logger,
|
||||
completion: @escaping (Result<[String: String], Error>) -> Void)
|
||||
completion: @escaping (Result<ResolvedGatewayService, Error>) -> Void)
|
||||
{
|
||||
self.service = NetService(domain: domain, type: type, name: name)
|
||||
self.completion = completion
|
||||
@@ -633,24 +653,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<ResolvedGatewayService, Error>) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
@@ -671,6 +694,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 +707,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayTXTResolverError: Error {
|
||||
enum GatewayServiceResolverError: Error {
|
||||
case cancelled
|
||||
case resolveFailed([String: NSNumber])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import Testing
|
||||
uptimems: 123,
|
||||
configpath: nil,
|
||||
statedir: nil,
|
||||
sessiondefaults: nil)
|
||||
sessiondefaults: nil,
|
||||
authmode: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw
|
||||
openclaw hooks install <path-or-spec>
|
||||
```
|
||||
|
||||
Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected.
|
||||
|
||||
Example `package.json`:
|
||||
|
||||
```json
|
||||
@@ -118,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/<id>`.
|
||||
|
||||
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
|
||||
@@ -128,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"] } } }
|
||||
---
|
||||
@@ -394,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -273,6 +273,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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -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/<userId>` or `users/<email>` (email addresses are accepted).
|
||||
- Direct messages: `users/<userId>` (recommended) or raw email `name@example.com` (mutable principal).
|
||||
- Deprecated: `users/<email>` is treated as a user id, not an email allowlist.
|
||||
- Spaces: `spaces/<spaceId>`.
|
||||
|
||||
## Config highlights
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
@@ -233,6 +234,8 @@ Manual reply tags are supported:
|
||||
- `[[reply_to_current]]`
|
||||
- `[[reply_to:<id>]]`
|
||||
|
||||
Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
|
||||
|
||||
## Media, chunking, and delivery
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -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<bot_token>/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:
|
||||
|
||||
@@ -412,9 +415,11 @@ curl "https://api.telegram.org/bot<bot_token>/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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -649,7 +654,7 @@ openclaw message send --channel telegram --target @name --message "hi"
|
||||
|
||||
<Accordion title="Commands work partially or not at all">
|
||||
|
||||
- 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 +684,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.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
|
||||
@@ -694,7 +699,7 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -90,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:
|
||||
@@ -192,6 +192,9 @@ openclaw hooks install <path-or-spec>
|
||||
|
||||
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/<id>`
|
||||
|
||||
@@ -44,6 +44,9 @@ openclaw plugins install <path-or-spec>
|
||||
|
||||
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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -535,7 +531,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -94,12 +94,19 @@ The Gateway advertises small non‑secret hints to make UI flows convenient:
|
||||
- `gatewayPort=<port>` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `canvasPort=<port>` (only when the canvas host is enabled; default `18793`)
|
||||
- `canvasPort=<port>` (only when the canvas host is enabled; currently the same as `gatewayPort`)
|
||||
- `sshPort=<port>` (defaults to 22 when not overridden)
|
||||
- `transport=gateway`
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint)
|
||||
- `tailnetDns=<magicdns>` (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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -933,6 +933,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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1889,9 +1890,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 +1936,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 +1988,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 +2022,7 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <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 +2067,18 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <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://<gateway-host>:<gateway.port>/__openclaw__/canvas/`
|
||||
- `http://<gateway-host>:<gateway.port>/__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/`.
|
||||
|
||||
@@ -61,7 +61,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
|
||||
## Strict validation
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
When validation fails:
|
||||
|
||||
@@ -64,10 +64,17 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
- `gatewayPort=18789` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `canvasPort=18793` (default canvas host port; serves `/__openclaw__/canvas/`)
|
||||
- `canvasPort=<port>` (canvas host port; currently the same as `gatewayPort` when the canvas host is enabled)
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint or binary)
|
||||
- `tailnetDns=<magicdns>` (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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -71,6 +71,11 @@ 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.
|
||||
|
||||
`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 + docker socket):
|
||||
|
||||
```json5
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
267
docs/gateway/trusted-proxy-auth.md
Normal file
267
docs/gateway/trusted-proxy-auth.md
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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:
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -142,6 +142,9 @@ The **installer script** is the recommended way to install OpenClaw. It handles
|
||||
<Card title="Docker" href="/install/docker" icon="container">
|
||||
Containerized or headless deployments.
|
||||
</Card>
|
||||
<Card title="Podman" href="/install/podman" icon="container">
|
||||
Rootless container: run `setup-podman.sh` once, then the launch script.
|
||||
</Card>
|
||||
<Card title="Nix" href="/install/nix" icon="snowflake">
|
||||
Declarative install via Nix.
|
||||
</Card>
|
||||
|
||||
105
docs/install/podman.md
Normal file
105
docs/install/podman.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
|
||||
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`. Generate with: `openssl rand -hex 32`.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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 "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/__openclaw__/canvas/"}'
|
||||
openclaw nodes invoke --node "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.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://<gateway-magicdns>:18793/__openclaw__/canvas/`.
|
||||
Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18789/__openclaw__/canvas/`.
|
||||
|
||||
This server injects a live-reload client into HTML and reloads on file changes.
|
||||
The A2UI host lives at `http://<gateway-host>:18793/__openclaw__/a2ui/`.
|
||||
The A2UI host lives at `http://<gateway-host>:18789/__openclaw__/a2ui/`.
|
||||
|
||||
Canvas commands (foreground only):
|
||||
|
||||
|
||||
@@ -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://<gateway-host>:18793/__openclaw__/canvas/"}'
|
||||
openclaw nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-host>: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":""}`.
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ A2UI host page on first open.
|
||||
Default A2UI host URL:
|
||||
|
||||
```
|
||||
http://<gateway-host>:18793/__openclaw__/a2ui/
|
||||
http://<gateway-host>:18789/__openclaw__/a2ui/
|
||||
```
|
||||
|
||||
### A2UI commands (v0.8)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
55
docs/providers/nvidia.md
Normal file
55
docs/providers/nvidia.md
Normal file
@@ -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).
|
||||
@@ -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.<id>.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.<id>` keys are errors unless a plugin manifest declares the channel id.
|
||||
|
||||
@@ -411,7 +411,7 @@ Actions:
|
||||
- `openclaw browser select 9 OptionA OptionB`
|
||||
- `openclaw browser download e12 report.pdf`
|
||||
- `openclaw browser waitfordownload report.pdf`
|
||||
- `openclaw browser upload /tmp/file.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"`
|
||||
@@ -447,6 +447,8 @@ Notes:
|
||||
- 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="<n>"`).
|
||||
|
||||
@@ -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.<id>.config`.
|
||||
|
||||
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
@@ -138,6 +141,10 @@ becomes `name/<fileBase>`.
|
||||
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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -44,6 +44,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.
|
||||
|
||||
@@ -133,7 +133,7 @@ Hook 包可以附带依赖;它们将安装在 `~/.openclaw/hooks/<id>` 下。
|
||||
---
|
||||
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"] } } }
|
||||
---
|
||||
|
||||
@@ -724,7 +724,7 @@ Telegram 反应作为**单独的 `message_reaction` 事件**到达,而不是
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`:每话题提及门控覆盖。
|
||||
- `channels.telegram.capabilities.inlineButtons`:`off | dm | group | all | allowlist`(默认:allowlist)。
|
||||
- `channels.telegram.accounts.<account>.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)。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,7 +15,7 @@ x-i18n:
|
||||
|
||||
# 系统提示词
|
||||
|
||||
OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 p-coding-agent 默认提示词。
|
||||
OpenClaw 为每次智能体运行构建自定义系统提示词。该提示词由 **OpenClaw 拥有**,不使用 pi-coding-agent 默认提示词。
|
||||
|
||||
该提示词由 OpenClaw 组装并注入到每次智能体运行中。
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
summary: 如何提交高信号 PR
|
||||
title: 提交 PR
|
||||
---
|
||||
|
||||
# 提交 PR
|
||||
|
||||
该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting a PR](/help/submitting-a-pr)。
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
summary: 如何提交高信号 Issue
|
||||
title: 提交 Issue
|
||||
---
|
||||
|
||||
# 提交 Issue
|
||||
|
||||
该页面是英文文档的中文占位版本,完整内容请先参考英文版:[Submitting an Issue](/help/submitting-an-issue)。
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
export type ResolvedBlueBubblesAccount = {
|
||||
|
||||
@@ -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,15 @@ 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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("listActions", () => {
|
||||
@@ -94,6 +101,31 @@ describe("bluebubblesMessageActions", () => {
|
||||
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 = bluebubblesMessageActions.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", () => {
|
||||
@@ -189,6 +221,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(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
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: {
|
||||
|
||||
@@ -23,7 +23,7 @@ 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";
|
||||
@@ -71,6 +71,18 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"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<ChannelMessageActionName>();
|
||||
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<string> => {
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
@@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("downloadBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -242,6 +249,8 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -342,4 +351,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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
@@ -64,7 +65,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password };
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
@@ -169,7 +170,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;
|
||||
@@ -238,7 +240,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 +250,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");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
@@ -13,12 +14,18 @@ vi.mock("./accounts.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("chat", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -73,6 +80,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 +208,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 +377,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesChatOpts = {
|
||||
@@ -25,7 +26,15 @@ function resolveAccount(params: BlueBubblesChatOpts) {
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password };
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markBlueBubblesChatRead(
|
||||
@@ -36,7 +45,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 +70,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 +108,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 +151,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 +192,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 +233,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 +274,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 +312,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 +346,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`,
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
resolveBlueBubblesMessageId,
|
||||
resolveReplyContextFromCache,
|
||||
} from "./monitor-reply-cache.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<string>();
|
||||
const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
|
||||
|
||||
export function logVerbose(
|
||||
core: BlueBubblesCoreRuntime,
|
||||
@@ -110,6 +112,7 @@ export async function processMessage(
|
||||
target: WebhookTarget,
|
||||
): Promise<void> {
|
||||
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;
|
||||
@@ -639,6 +642,15 @@ export async function processMessage(
|
||||
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 = {
|
||||
Body: body,
|
||||
@@ -721,7 +733,9 @@ export async function processMessage(
|
||||
...prefixOptions,
|
||||
deliver: async (payload, info) => {
|
||||
const rawReplyToId =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
privateApiEnabled && typeof payload.replyToId === "string"
|
||||
? payload.replyToId.trim()
|
||||
: "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
@@ -737,7 +751,9 @@ export async function processMessage(
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
const text = sanitizeReplyDirectiveText(
|
||||
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||
);
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : undefined;
|
||||
@@ -771,7 +787,9 @@ export async function processMessage(
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
const text = sanitizeReplyDirectiveText(
|
||||
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||
);
|
||||
const chunks =
|
||||
chunkMode === "newline"
|
||||
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
||||
|
||||
@@ -557,6 +557,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 = {};
|
||||
|
||||
@@ -398,23 +398,31 @@ 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 fallbackTargets: WebhookTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
if (!token) {
|
||||
return true;
|
||||
fallbackTargets.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;
|
||||
strictMatches.push(target);
|
||||
if (strictMatches.length > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const matching = strictMatches.length > 0 ? strictMatches : fallbackTargets;
|
||||
|
||||
if (matching.length === 0) {
|
||||
res.statusCode = 401;
|
||||
@@ -425,24 +433,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;
|
||||
@@ -484,6 +498,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,
|
||||
|
||||
@@ -85,6 +85,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.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesReactionOpts = {
|
||||
@@ -123,7 +124,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password };
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||
@@ -160,7 +161,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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
@@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("send", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -611,6 +618,46 @@ 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);
|
||||
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-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({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
normalizeBlueBubblesHandle,
|
||||
@@ -397,6 +398,7 @@ export async function sendMessageBlueBubbles(
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
|
||||
|
||||
const target = resolveSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
@@ -422,18 +424,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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type {
|
||||
FeishuConfig,
|
||||
FeishuAccountConfig,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user