Compare commits
417 Commits
codex/arch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd372836e | ||
|
|
ce6a48195a | ||
|
|
8a05c05596 | ||
|
|
43513cd1df | ||
|
|
5bb5d7dab4 | ||
|
|
9fb78453e0 | ||
|
|
d78e13f545 | ||
|
|
6b4c24c2e5 | ||
|
|
598f1826d8 | ||
|
|
5e417b44e1 | ||
|
|
b71686ab44 | ||
|
|
c3be293dd5 | ||
|
|
e78129a4d9 | ||
|
|
6a6f1b5351 | ||
|
|
751d5b7849 | ||
|
|
6526074c85 | ||
|
|
0a842de354 | ||
|
|
2364e45fe4 | ||
|
|
e635cedb85 | ||
|
|
d54ebed7c8 | ||
|
|
d1d46c6cfb | ||
|
|
f1802a5bc7 | ||
|
|
6e20c4baa0 | ||
|
|
42ca447189 | ||
|
|
fac64c2392 | ||
|
|
39a4fe576d | ||
|
|
c3972982b5 | ||
|
|
cadbaa34c1 | ||
|
|
994b42a5a5 | ||
|
|
aed1f6d807 | ||
|
|
09cf6d80ec | ||
|
|
7abfff756d | ||
|
|
c7134e629c | ||
|
|
11d71ca352 | ||
|
|
5a5e84ca1d | ||
|
|
fa71ad7c5d | ||
|
|
23fef04c4e | ||
|
|
1b18742e8e | ||
|
|
a20ba74978 | ||
|
|
3da66718f4 | ||
|
|
acf32287b4 | ||
|
|
916f496b51 | ||
|
|
f6b3245a7b | ||
|
|
62ddc9d9e0 | ||
|
|
46854a84a4 | ||
|
|
7b00a0620a | ||
|
|
a05da76718 | ||
|
|
5408a3d1a4 | ||
|
|
39053bddd7 | ||
|
|
a7401366ef | ||
|
|
083f825122 | ||
|
|
b26edfe1ff | ||
|
|
740b345a2e | ||
|
|
483926a6fb | ||
|
|
2e0b445b46 | ||
|
|
16e055c083 | ||
|
|
e4d0fdcc15 | ||
|
|
fb293fa36f | ||
|
|
a4a5ed8948 | ||
|
|
4edab304db | ||
|
|
3d097f1052 | ||
|
|
e18ab85f08 | ||
|
|
5f600e117d | ||
|
|
35ac1f6e07 | ||
|
|
4e45a663e7 | ||
|
|
c64893a9c2 | ||
|
|
ad4536fd7e | ||
|
|
1cabb053ad | ||
|
|
23a119c6ea | ||
|
|
42801f6178 | ||
|
|
5b7ae24e30 | ||
|
|
a2e1991ed3 | ||
|
|
fb3550ef5e | ||
|
|
58889f984f | ||
|
|
06311f89e0 | ||
|
|
fa275fddf8 | ||
|
|
96e1c37685 | ||
|
|
a39c440d39 | ||
|
|
4838e3934b | ||
|
|
4266e260e1 | ||
|
|
85a5d64d8f | ||
|
|
93fbe26adb | ||
|
|
87eeab7034 | ||
|
|
fcabecc9a4 | ||
|
|
18fa2992f9 | ||
|
|
cb89325cd8 | ||
|
|
4c614c230d | ||
|
|
aa78a0c00e | ||
|
|
9b6f286ac2 | ||
|
|
faa9faa767 | ||
|
|
d3ffa1e4e7 | ||
|
|
dbc9d3dd70 | ||
|
|
50ce9ac1c6 | ||
|
|
f6948ce405 | ||
|
|
ba1bb8505f | ||
|
|
06845a1974 | ||
|
|
7c3af3726f | ||
|
|
897cda7d99 | ||
|
|
5607da90d5 | ||
|
|
dc86b6d72a | ||
|
|
99e53612cb | ||
|
|
c6968c39d6 | ||
|
|
4c60956d8e | ||
|
|
3bda64f75c | ||
|
|
57f1cf66ad | ||
|
|
192f859325 | ||
|
|
6cb2fc501a | ||
|
|
df536c3248 | ||
|
|
d774b3f274 | ||
|
|
dc06e4fd22 | ||
|
|
0fae764f10 | ||
|
|
95f890a8b2 | ||
|
|
f0a0a6a5b4 | ||
|
|
68a274c7b3 | ||
|
|
d25f6f1833 | ||
|
|
f1e012e0fc | ||
|
|
9f8af3604d | ||
|
|
faa8e27291 | ||
|
|
8ac4d13a6f | ||
|
|
0c2e6fe97f | ||
|
|
f09f98532c | ||
|
|
ecec0d5b2c | ||
|
|
dfc157e1a2 | ||
|
|
3a72d2d6de | ||
|
|
e56dde815e | ||
|
|
397b0d85f5 | ||
|
|
709c730e2a | ||
|
|
a562fb5550 | ||
|
|
96f21c37b4 | ||
|
|
6c7526f8a0 | ||
|
|
ce878a9eb1 | ||
|
|
36a59d5c79 | ||
|
|
9af42c6590 | ||
|
|
098a0d0d0d | ||
|
|
f2849c2417 | ||
|
|
8d805a02fd | ||
|
|
5036ed2699 | ||
|
|
06fc498d54 | ||
|
|
94ab044387 | ||
|
|
4d9ae5899d | ||
|
|
b90eef50ec | ||
|
|
829beced04 | ||
|
|
3db2cfef07 | ||
|
|
d689b3fc89 | ||
|
|
254ea0c65e | ||
|
|
9c7da58770 | ||
|
|
fe863c5400 | ||
|
|
a73e517ae3 | ||
|
|
2afd65741c | ||
|
|
61965e500f | ||
|
|
47e412bd0b | ||
|
|
4a0341ed03 | ||
|
|
4386a0ace8 | ||
|
|
e3afaca1a6 | ||
|
|
f7fe75a68b | ||
|
|
4ac355babb | ||
|
|
84ee6fbb76 | ||
|
|
b36e456b09 | ||
|
|
914fc265c5 | ||
|
|
1ba70c3707 | ||
|
|
80110c550f | ||
|
|
991eb2ef03 | ||
|
|
4aef83016f | ||
|
|
03c86b3dee | ||
|
|
62e6eb117e | ||
|
|
218f8d74b6 | ||
|
|
2d24f35016 | ||
|
|
9c21637fe9 | ||
|
|
f62be0ddcf | ||
|
|
ab97cc3f11 | ||
|
|
de9f2dc227 | ||
|
|
4f00b3b534 | ||
|
|
f1ce679929 | ||
|
|
65594f972c | ||
|
|
61ae7e033b | ||
|
|
1fb30fbf78 | ||
|
|
a2174f1ff1 | ||
|
|
cf2a66b508 | ||
|
|
e009920256 | ||
|
|
a19f058145 | ||
|
|
f91fad1710 | ||
|
|
ac18a734ac | ||
|
|
55e12bd236 | ||
|
|
c95d1c101b | ||
|
|
6309b1da6c | ||
|
|
a953cb5209 | ||
|
|
d518260bb8 | ||
|
|
41628770f5 | ||
|
|
aa172f2169 | ||
|
|
c38295c7a2 | ||
|
|
0f69b5c11a | ||
|
|
8e132aed6e | ||
|
|
9486f6e379 | ||
|
|
f3971571fe | ||
|
|
009f494cd9 | ||
|
|
192151610f | ||
|
|
20001a50c5 | ||
|
|
801e4bede6 | ||
|
|
bbfeb0b6f9 | ||
|
|
c3b05fc4d9 | ||
|
|
14eb49c18a | ||
|
|
d80b83e8e3 | ||
|
|
a245916dcb | ||
|
|
ac850e815b | ||
|
|
2884ac13b2 | ||
|
|
35bc00c55b | ||
|
|
bbd62469fa | ||
|
|
da8fb70525 | ||
|
|
73e08775d7 | ||
|
|
566e4cf77b | ||
|
|
5841e3b493 | ||
|
|
aeb2adf240 | ||
|
|
38807fff20 | ||
|
|
ec2278192d | ||
|
|
d03c110a0a | ||
|
|
a54d3dc679 | ||
|
|
628b55a825 | ||
|
|
c7cebd608b | ||
|
|
7d50e7fa85 | ||
|
|
3c806a9692 | ||
|
|
247a19a694 | ||
|
|
83a267e2f3 | ||
|
|
ae02f40144 | ||
|
|
8412498c2c | ||
|
|
0e825ece05 | ||
|
|
7f52a8a3a5 | ||
|
|
1878272f67 | ||
|
|
ca757b6b77 | ||
|
|
98298f7931 | ||
|
|
b7c39aa4d4 | ||
|
|
f1be7d4cb3 | ||
|
|
a94e21e0a7 | ||
|
|
46ccbacbd9 | ||
|
|
3b79494cbf | ||
|
|
7bbd01379e | ||
|
|
ca74eb37da | ||
|
|
0aa4950d21 | ||
|
|
7bc7dd055a | ||
|
|
3de8c3d053 | ||
|
|
8dea2b124b | ||
|
|
003ca0123d | ||
|
|
36df0095c4 | ||
|
|
0fd3632d68 | ||
|
|
22528af34d | ||
|
|
f60017d725 | ||
|
|
7a596b2305 | ||
|
|
60253111a3 | ||
|
|
962a8fea90 | ||
|
|
14e84cf0b3 | ||
|
|
9117836981 | ||
|
|
ebb6738e9d | ||
|
|
34adde2e41 | ||
|
|
815d603ce2 | ||
|
|
a6021cf78f | ||
|
|
e466b55661 | ||
|
|
7187d1da06 | ||
|
|
517570d0fb | ||
|
|
66894db1b6 | ||
|
|
3496ecc2ec | ||
|
|
e5b50ba0d5 | ||
|
|
30ddeabfdc | ||
|
|
071319545f | ||
|
|
e1a39c6ba5 | ||
|
|
22c1bda2a0 | ||
|
|
cb78f38da9 | ||
|
|
e121aad2c1 | ||
|
|
392047b49f | ||
|
|
6b9ebffebb | ||
|
|
feb9a3b5b2 | ||
|
|
51519b4086 | ||
|
|
0a8885d6c1 | ||
|
|
cb552bcc42 | ||
|
|
d9e9a9e819 | ||
|
|
13be4b4cc2 | ||
|
|
b28cf6a8a4 | ||
|
|
d57c327d45 | ||
|
|
089c8bc65e | ||
|
|
faf81c5574 | ||
|
|
a18f7d7d35 | ||
|
|
9f2a01d972 | ||
|
|
0b11ee48f8 | ||
|
|
624d536551 | ||
|
|
1dd857f6a6 | ||
|
|
65a2917c8f | ||
|
|
36f394c299 | ||
|
|
3dfd8eef7f | ||
|
|
401ffb59f5 | ||
|
|
7fb142d115 | ||
|
|
639f78d257 | ||
|
|
dcbcecfb85 | ||
|
|
f1e4f8e8d2 | ||
|
|
91104ac740 | ||
|
|
5b1836d700 | ||
|
|
7a57082466 | ||
|
|
9d772d6eab | ||
|
|
ff6541f69d | ||
|
|
5a41229a6d | ||
|
|
e1b5ffadca | ||
|
|
fb18034011 | ||
|
|
bfe979dd5b | ||
|
|
12ad809e79 | ||
|
|
8268c28053 | ||
|
|
44cd4fb55f | ||
|
|
0c4fdf1284 | ||
|
|
f4f0b171d3 | ||
|
|
8c01347989 | ||
|
|
c7cbc8cc0b | ||
|
|
79d7fdce93 | ||
|
|
a0445b192e | ||
|
|
1c1a3b6a75 | ||
|
|
dd10f290e8 | ||
|
|
191e1947c1 | ||
|
|
5508374669 | ||
|
|
7f86be1037 | ||
|
|
20728e1035 | ||
|
|
47b02435c1 | ||
|
|
75e6c8fe9c | ||
|
|
16129272dc | ||
|
|
f8eb23de1c | ||
|
|
34ee75b174 | ||
|
|
4443cc771a | ||
|
|
f69450b170 | ||
|
|
c4a4050ce4 | ||
|
|
009a10bce2 | ||
|
|
c37a92ca6e | ||
|
|
040c43ae21 | ||
|
|
f3097b4c09 | ||
|
|
0443ee82be | ||
|
|
22943f24a9 | ||
|
|
bcc725ffe2 | ||
|
|
b965ef3802 | ||
|
|
ddd921ff0b | ||
|
|
c5c2416ec2 | ||
|
|
94693f7ff0 | ||
|
|
513b4869d8 | ||
|
|
1d3e596021 | ||
|
|
608b9a9af2 | ||
|
|
a2fa799a5c | ||
|
|
03f18ec043 | ||
|
|
eaee01042b | ||
|
|
b48194a07e | ||
|
|
8467fb6601 | ||
|
|
d978ace90b | ||
|
|
68bc6effc0 | ||
|
|
53a34c39f6 | ||
|
|
3261a2a0b1 | ||
|
|
74b9ad010a | ||
|
|
a2a9a553e1 | ||
|
|
3abffe0967 | ||
|
|
afa95fade0 | ||
|
|
83d284610c | ||
|
|
a98ffa41d0 | ||
|
|
16567ba4e7 | ||
|
|
b8b1e2cf50 | ||
|
|
f6c57edd5c | ||
|
|
79e13e0a5e | ||
|
|
5b7b5529f1 | ||
|
|
126839380c | ||
|
|
74756b91b7 | ||
|
|
f7675eca6b | ||
|
|
59269f3534 | ||
|
|
25015161fe | ||
|
|
19126033dd | ||
|
|
b7ca56f662 | ||
|
|
83c5bc946d | ||
|
|
c86de678f3 | ||
|
|
58cf9b865f | ||
|
|
8404f56841 | ||
|
|
30a94dfd3b | ||
|
|
510f4276b5 | ||
|
|
7b61ca1b06 | ||
|
|
a837ebdd67 | ||
|
|
a290f5e50f | ||
|
|
ffc1d5459c | ||
|
|
6ae68faf5f | ||
|
|
0f0cecd2e8 | ||
|
|
7b151afeeb | ||
|
|
371b3d22f5 | ||
|
|
42b9212eb2 | ||
|
|
f8c70bf1f1 | ||
|
|
de86e25fd4 | ||
|
|
8884643f40 | ||
|
|
002cc07322 | ||
|
|
f19cb738af | ||
|
|
4cc0bb07c1 | ||
|
|
b736a92e19 | ||
|
|
c70837f07d | ||
|
|
62b7b350c9 | ||
|
|
9a9db87952 | ||
|
|
60a55c9cbe | ||
|
|
d7018aaf19 | ||
|
|
07d9f725b6 | ||
|
|
bea90b72e6 | ||
|
|
5f97645382 | ||
|
|
46f49eb6eb | ||
|
|
6e044ace28 | ||
|
|
b9c4db1a77 | ||
|
|
a996f60f11 | ||
|
|
757c2cc2de | ||
|
|
7d8d3d9d77 | ||
|
|
67da67b61a | ||
|
|
2661de384f | ||
|
|
859889aae9 | ||
|
|
91d37ccfc3 | ||
|
|
6ebcd853be | ||
|
|
b526098eb2 | ||
|
|
c749957c93 | ||
|
|
e5a1185796 | ||
|
|
be3f4a7966 | ||
|
|
198de10523 | ||
|
|
63e09f8267 | ||
|
|
2797ae1583 | ||
|
|
cc5bd57bd7 | ||
|
|
e9903c9133 | ||
|
|
e6911f0448 | ||
|
|
ef1346e503 | ||
|
|
ecfa79ee4c |
87
.agents/skills/openclaw-ghsa-maintainer/SKILL.md
Normal file
87
.agents/skills/openclaw-ghsa-maintainer/SKILL.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-ghsa-maintainer
|
||||||
|
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw GHSA Maintainer
|
||||||
|
|
||||||
|
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
|
||||||
|
|
||||||
|
## Respect advisory guardrails
|
||||||
|
|
||||||
|
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
|
||||||
|
- Ask permission before any publish action.
|
||||||
|
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
|
||||||
|
|
||||||
|
## Fetch and inspect advisory state
|
||||||
|
|
||||||
|
Fetch the current advisory and the latest published npm version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||||
|
npm view openclaw version --userconfig "$(mktemp)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
|
||||||
|
|
||||||
|
## Verify private fork PRs are closed
|
||||||
|
|
||||||
|
Before publishing, verify that the advisory's private fork has no open PRs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
|
||||||
|
gh pr list -R "$fork" --state open
|
||||||
|
```
|
||||||
|
|
||||||
|
The PR list must be empty before publish.
|
||||||
|
|
||||||
|
## Prepare advisory Markdown and JSON safely
|
||||||
|
|
||||||
|
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
|
||||||
|
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
|
||||||
|
|
||||||
|
Example pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /tmp/ghsa.desc.md <<'EOF'
|
||||||
|
<markdown description>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
jq -n --rawfile desc /tmp/ghsa.desc.md \
|
||||||
|
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
|
||||||
|
> /tmp/ghsa.patch.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply PATCH calls in the correct sequence
|
||||||
|
|
||||||
|
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
|
||||||
|
- Use separate calls when the advisory requires both fields.
|
||||||
|
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
|
||||||
|
|
||||||
|
Example shape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
|
||||||
|
--input /tmp/ghsa.patch.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish and verify success
|
||||||
|
|
||||||
|
After publish, re-fetch the advisory and confirm:
|
||||||
|
|
||||||
|
- `state=published`
|
||||||
|
- `published_at` is set
|
||||||
|
- the description does not contain literal escaped `\\n`
|
||||||
|
|
||||||
|
Verification pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||||
|
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common GHSA footguns
|
||||||
|
|
||||||
|
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||||
|
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||||
|
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||||
58
.agents/skills/openclaw-parallels-smoke/SKILL.md
Normal file
58
.agents/skills/openclaw-parallels-smoke/SKILL.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-parallels-smoke
|
||||||
|
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw Parallels Smoke
|
||||||
|
|
||||||
|
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
|
||||||
|
|
||||||
|
## Global rules
|
||||||
|
|
||||||
|
- Use the snapshot most closely matching the requested fresh baseline.
|
||||||
|
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
|
||||||
|
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
|
||||||
|
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
|
||||||
|
- Pass `--json` for machine-readable summaries.
|
||||||
|
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||||
|
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||||
|
|
||||||
|
## macOS flow
|
||||||
|
|
||||||
|
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||||
|
- Target the snapshot closest to `macOS 26.3.1 fresh`.
|
||||||
|
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||||
|
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
|
||||||
|
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||||
|
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
|
||||||
|
|
||||||
|
## Windows flow
|
||||||
|
|
||||||
|
- Preferred entrypoint: `pnpm test:parallels:windows`
|
||||||
|
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
|
||||||
|
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
|
||||||
|
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
|
||||||
|
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
|
||||||
|
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
|
||||||
|
|
||||||
|
## Linux flow
|
||||||
|
|
||||||
|
- Preferred entrypoint: `pnpm test:parallels:linux`
|
||||||
|
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
|
||||||
|
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
|
||||||
|
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
|
||||||
|
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
|
||||||
|
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
|
||||||
|
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
|
||||||
|
|
||||||
|
## Discord roundtrip
|
||||||
|
|
||||||
|
- Discord roundtrip is optional and should be enabled with:
|
||||||
|
- `--discord-token-env`
|
||||||
|
- `--discord-guild-id`
|
||||||
|
- `--discord-channel-id`
|
||||||
|
- Keep the Discord token only in a host env var.
|
||||||
|
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
|
||||||
|
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
|
||||||
|
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
|
||||||
|
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.
|
||||||
75
.agents/skills/openclaw-pr-maintainer/SKILL.md
Normal file
75
.agents/skills/openclaw-pr-maintainer/SKILL.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-pr-maintainer
|
||||||
|
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw PR Maintainer
|
||||||
|
|
||||||
|
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
|
||||||
|
|
||||||
|
## Apply close and triage labels correctly
|
||||||
|
|
||||||
|
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
|
||||||
|
- Do not manually close plus manually comment for these reasons.
|
||||||
|
- `r:*` labels can be used on both issues and PRs.
|
||||||
|
- Current reasons:
|
||||||
|
- `r: skill`
|
||||||
|
- `r: support`
|
||||||
|
- `r: no-ci-pr`
|
||||||
|
- `r: too-many-prs`
|
||||||
|
- `r: testflight`
|
||||||
|
- `r: third-party-extension`
|
||||||
|
- `r: moltbook`
|
||||||
|
- `r: spam`
|
||||||
|
- `invalid`
|
||||||
|
- `dirty` for PRs only
|
||||||
|
|
||||||
|
## Enforce the bug-fix evidence bar
|
||||||
|
|
||||||
|
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||||
|
- Before landing, require:
|
||||||
|
1. symptom evidence such as a repro, logs, or a failing test
|
||||||
|
2. a verified root cause in code with file/line
|
||||||
|
3. a fix that touches the implicated code path
|
||||||
|
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
|
||||||
|
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||||
|
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
|
||||||
|
|
||||||
|
## Handle GitHub text safely
|
||||||
|
|
||||||
|
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
|
||||||
|
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
|
||||||
|
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
|
||||||
|
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
|
||||||
|
|
||||||
|
## Search broadly before deciding
|
||||||
|
|
||||||
|
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
|
||||||
|
- Use `--repo openclaw/openclaw` with `--match title,body` first.
|
||||||
|
- Add `--match comments` when triaging follow-up discussion.
|
||||||
|
- Do not stop at the first 500 results when the task requires a full search.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||||
|
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||||
|
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||||
|
--json number,title,state,url,updatedAt -- "auto update" \
|
||||||
|
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Follow PR review and landing hygiene
|
||||||
|
|
||||||
|
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||||
|
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||||
|
- When landing or merging any PR, follow the global `/landpr` process.
|
||||||
|
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||||
|
- Keep commit messages concise and action-oriented.
|
||||||
|
- Group related changes; avoid bundling unrelated refactors.
|
||||||
|
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||||
|
|
||||||
|
## Extra safety
|
||||||
|
|
||||||
|
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
|
||||||
|
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.
|
||||||
74
.agents/skills/openclaw-release-maintainer/SKILL.md
Normal file
74
.agents/skills/openclaw-release-maintainer/SKILL.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-release-maintainer
|
||||||
|
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw Release Maintainer
|
||||||
|
|
||||||
|
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||||
|
|
||||||
|
## Respect release guardrails
|
||||||
|
|
||||||
|
- Do not change version numbers without explicit operator approval.
|
||||||
|
- Ask permission before any npm publish or release step.
|
||||||
|
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
|
||||||
|
|
||||||
|
## Keep release channel naming aligned
|
||||||
|
|
||||||
|
- `stable`: tagged releases only, with npm dist-tag `latest`
|
||||||
|
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||||
|
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||||
|
- `dev`: moving head on `main`
|
||||||
|
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||||
|
|
||||||
|
## Handle versions and release files consistently
|
||||||
|
|
||||||
|
- Version locations include:
|
||||||
|
- `package.json`
|
||||||
|
- `apps/android/app/build.gradle.kts`
|
||||||
|
- `apps/ios/Sources/Info.plist`
|
||||||
|
- `apps/ios/Tests/Info.plist`
|
||||||
|
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
|
||||||
|
- `docs/install/updating.md`
|
||||||
|
- Peekaboo Xcode project and plist version fields
|
||||||
|
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||||
|
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||||
|
|
||||||
|
## Build changelog-backed release notes
|
||||||
|
|
||||||
|
- Changelog entries should be user-facing, not internal release-process notes.
|
||||||
|
- When cutting a mac release with a beta GitHub prerelease:
|
||||||
|
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||||
|
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||||
|
- use release notes from the matching `CHANGELOG.md` version section
|
||||||
|
- attach at least the zip and dSYM zip, plus dmg if available
|
||||||
|
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||||
|
- `### Changes` first
|
||||||
|
- `### Fixes` deduped with user-facing fixes first
|
||||||
|
|
||||||
|
## Run publish-time validation
|
||||||
|
|
||||||
|
Before tagging or publishing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx scripts/release-check.ts
|
||||||
|
pnpm release:check
|
||||||
|
pnpm test:install:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
For a non-root smoke path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the right auth flow
|
||||||
|
|
||||||
|
- Core `openclaw` publish uses GitHub trusted publishing.
|
||||||
|
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||||
|
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||||
|
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||||
|
|
||||||
|
## GHSA advisory work
|
||||||
|
|
||||||
|
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
|
||||||
71
.agents/skills/openclaw-test-heap-leaks/SKILL.md
Normal file
71
.agents/skills/openclaw-test-heap-leaks/SKILL.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-test-heap-leaks
|
||||||
|
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw Test Heap Leaks
|
||||||
|
|
||||||
|
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Reproduce the failing shape first.
|
||||||
|
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
|
||||||
|
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
|
||||||
|
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
|
||||||
|
- If the report is about a specific shard or worker budget, preserve that shape.
|
||||||
|
|
||||||
|
2. Wait for repeated snapshots before concluding anything.
|
||||||
|
- Take at least two intervals from the same lane.
|
||||||
|
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
|
||||||
|
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
|
||||||
|
|
||||||
|
3. Classify the growth before choosing a fix.
|
||||||
|
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
|
||||||
|
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
|
||||||
|
|
||||||
|
4. Fix the right layer.
|
||||||
|
- For retained transformed-module growth in shared workers:
|
||||||
|
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
|
||||||
|
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
|
||||||
|
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
|
||||||
|
- For real leaks:
|
||||||
|
- Patch the implicated test or runtime cleanup path.
|
||||||
|
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
|
||||||
|
|
||||||
|
5. Verify with the most direct proof.
|
||||||
|
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
|
||||||
|
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
|
||||||
|
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
|
||||||
|
|
||||||
|
## Heuristics
|
||||||
|
|
||||||
|
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
|
||||||
|
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
|
||||||
|
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
|
||||||
|
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
|
||||||
|
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
|
||||||
|
|
||||||
|
## Snapshot Comparison
|
||||||
|
|
||||||
|
- Direct comparison:
|
||||||
|
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
|
||||||
|
- Auto-select earliest/latest snapshots per PID within one lane:
|
||||||
|
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
|
||||||
|
- Useful flags:
|
||||||
|
- `--top 40`
|
||||||
|
- `--min-kb 32`
|
||||||
|
- `--pid 16133`
|
||||||
|
|
||||||
|
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
|
||||||
|
|
||||||
|
## Output Expectations
|
||||||
|
|
||||||
|
When using this skill, report:
|
||||||
|
|
||||||
|
- The exact reproduce command.
|
||||||
|
- Which lane and PID were compared.
|
||||||
|
- The dominant retained object families from the snapshot delta.
|
||||||
|
- Whether the issue is a real leak or shared-worker retained module growth.
|
||||||
|
- The concrete fix or impact-reduction patch.
|
||||||
|
- What you verified, and what snapshot overhead prevented you from verifying.
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Test Heap Leaks"
|
||||||
|
short_description: "Investigate test OOMs with heap snapshots"
|
||||||
|
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.error(
|
||||||
|
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
top: 30,
|
||||||
|
minKb: 64,
|
||||||
|
laneDir: null,
|
||||||
|
pid: null,
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--top") {
|
||||||
|
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--min-kb") {
|
||||||
|
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--lane-dir") {
|
||||||
|
options.laneDir = argv[index + 1] ?? null;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--pid") {
|
||||||
|
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.files.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(options.top) || options.top <= 0) {
|
||||||
|
fail("--top must be a positive integer");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
|
||||||
|
fail("--min-kb must be a non-negative integer");
|
||||||
|
}
|
||||||
|
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
|
||||||
|
fail("--pid must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeapFilename(filePath) {
|
||||||
|
const base = path.basename(filePath);
|
||||||
|
const match = base.match(
|
||||||
|
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
|
||||||
|
);
|
||||||
|
if (!match?.groups) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
pid: Number.parseInt(match.groups.pid, 10),
|
||||||
|
stamp: match.groups.stamp,
|
||||||
|
sequence: Number.parseInt(match.groups.seq, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePair(options) {
|
||||||
|
if (options.laneDir) {
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(options.laneDir)
|
||||||
|
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
|
||||||
|
.filter((entry) => entry !== null)
|
||||||
|
.filter((entry) => options.pid === null || entry.pid === options.pid)
|
||||||
|
.toSorted((left, right) => {
|
||||||
|
if (left.pid !== right.pid) {
|
||||||
|
return left.pid - right.pid;
|
||||||
|
}
|
||||||
|
if (left.stamp !== right.stamp) {
|
||||||
|
return left.stamp.localeCompare(right.stamp);
|
||||||
|
}
|
||||||
|
return left.sequence - right.sequence;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
fail(`No matching heap snapshots found in ${options.laneDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const group = groups.get(entry.pid) ?? [];
|
||||||
|
group.push(entry);
|
||||||
|
groups.set(entry.pid, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = Array.from(groups.values())
|
||||||
|
.map((group) => ({
|
||||||
|
pid: group[0].pid,
|
||||||
|
before: group[0],
|
||||||
|
after: group.at(-1),
|
||||||
|
count: group.length,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.count >= 2);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen =
|
||||||
|
options.pid !== null
|
||||||
|
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
|
||||||
|
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
|
||||||
|
|
||||||
|
if (!chosen) {
|
||||||
|
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: chosen.before.filePath,
|
||||||
|
after: chosen.after.filePath,
|
||||||
|
pid: chosen.pid,
|
||||||
|
snapshotCount: chosen.count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.files.length !== 2) {
|
||||||
|
printUsage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: options.files[0],
|
||||||
|
after: options.files[1],
|
||||||
|
pid: null,
|
||||||
|
snapshotCount: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSummary(filePath) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
const meta = data.snapshot?.meta;
|
||||||
|
if (!meta) {
|
||||||
|
fail(`Invalid heap snapshot: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeFieldCount = meta.node_fields.length;
|
||||||
|
const typeNames = meta.node_types[0];
|
||||||
|
const strings = data.strings;
|
||||||
|
const typeIndex = meta.node_fields.indexOf("type");
|
||||||
|
const nameIndex = meta.node_fields.indexOf("name");
|
||||||
|
const selfSizeIndex = meta.node_fields.indexOf("self_size");
|
||||||
|
|
||||||
|
const summary = new Map();
|
||||||
|
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
|
||||||
|
const type = typeNames[data.nodes[offset + typeIndex]];
|
||||||
|
const name = strings[data.nodes[offset + nameIndex]];
|
||||||
|
const selfSize = data.nodes[offset + selfSizeIndex];
|
||||||
|
const key = `${type}\t${name}`;
|
||||||
|
const current = summary.get(key) ?? {
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
selfSize: 0,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
current.selfSize += selfSize;
|
||||||
|
current.count += 1;
|
||||||
|
summary.set(key, current);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nodeCount: data.snapshot.node_count,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (Math.abs(bytes) >= 1024 ** 2) {
|
||||||
|
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
|
||||||
|
}
|
||||||
|
if (Math.abs(bytes) >= 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
}
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDelta(bytes) {
|
||||||
|
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text, maxLength) {
|
||||||
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const pair = resolvePair(options);
|
||||||
|
const before = loadSummary(pair.before);
|
||||||
|
const after = loadSummary(pair.after);
|
||||||
|
const minBytes = options.minKb * 1024;
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const [key, next] of after.summary) {
|
||||||
|
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
|
||||||
|
const sizeDelta = next.selfSize - previous.selfSize;
|
||||||
|
const countDelta = next.count - previous.count;
|
||||||
|
if (sizeDelta < minBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
type: next.type,
|
||||||
|
name: next.name,
|
||||||
|
sizeDelta,
|
||||||
|
countDelta,
|
||||||
|
afterSize: next.selfSize,
|
||||||
|
afterCount: next.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort(
|
||||||
|
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`before: ${pair.before}`);
|
||||||
|
console.log(`after: ${pair.after}`);
|
||||||
|
if (pair.pid !== null) {
|
||||||
|
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
|
||||||
|
);
|
||||||
|
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log("No entries exceeded the minimum delta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows.slice(0, options.top)) {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
formatDelta(row.sizeDelta).padStart(11),
|
||||||
|
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
|
||||||
|
row.type.padEnd(16),
|
||||||
|
truncate(row.name || "(empty)", 96),
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@ -1,7 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|
||||||
# Sensitive files – docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
# Sensitive files – scripts/docker/setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||||
# into the project root; keep it out of the build context.
|
# into the project root; keep it out of the build context.
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -7,7 +7,8 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
|
||||||
|
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: bug_type
|
id: bug_type
|
||||||
attributes:
|
attributes:
|
||||||
@ -23,35 +24,35 @@ body:
|
|||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
label: Summary
|
label: Summary
|
||||||
description: One-sentence statement of what is broken.
|
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
|
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: repro
|
id: repro
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
description: Provide the shortest deterministic repro path.
|
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Configure channel X.
|
1. Start OpenClaw 2026.2.17 with the attached config.
|
||||||
2. Send message Y.
|
2. Send a Telegram thread reply in the affected chat.
|
||||||
3. Run command Z.
|
3. Observe no reply and confirm the attached `reply target not found` log line.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected
|
id: expected
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected behavior
|
label: Expected behavior
|
||||||
description: What should happen if the bug does not exist.
|
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
placeholder: Agent posts a reply in the same thread.
|
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: actual
|
id: actual
|
||||||
attributes:
|
attributes:
|
||||||
label: Actual behavior
|
label: Actual behavior
|
||||||
description: What happened instead, including user-visible errors.
|
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@ -92,12 +93,6 @@ body:
|
|||||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
|
||||||
id: config_location
|
|
||||||
attributes:
|
|
||||||
label: Config file / key location
|
|
||||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
|
||||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: provider_setup_details
|
id: provider_setup_details
|
||||||
attributes:
|
attributes:
|
||||||
@ -111,27 +106,28 @@ body:
|
|||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Logs, screenshots, and evidence
|
label: Logs, screenshots, and evidence
|
||||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
|
||||||
render: shell
|
render: shell
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: impact
|
id: impact
|
||||||
attributes:
|
attributes:
|
||||||
label: Impact and severity
|
label: Impact and severity
|
||||||
description: |
|
description: |
|
||||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
|
||||||
|
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
Include:
|
Include:
|
||||||
- Affected users/systems/channels
|
- Affected users/systems/channels
|
||||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||||
- Frequency (always/intermittent/edge case)
|
- Frequency (always/intermittent/edge case)
|
||||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Affected: Telegram group users on <version>
|
Affected: Telegram group users on 2026.2.17
|
||||||
Severity: High (blocks replies)
|
Severity: High (blocks thread replies)
|
||||||
Frequency: 100% repro
|
Frequency: 4/4 observed attempts
|
||||||
Consequence: Agents cannot respond in threads
|
Consequence: Agents do not respond in the affected threads
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional_information
|
id: additional_information
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
|
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||||
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
|
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.
|
||||||
|
|||||||
7
.github/labeler.yml
vendored
7
.github/labeler.yml
vendored
@ -165,7 +165,10 @@
|
|||||||
- "Dockerfile.*"
|
- "Dockerfile.*"
|
||||||
- "docker-compose.yml"
|
- "docker-compose.yml"
|
||||||
- "docker-setup.sh"
|
- "docker-setup.sh"
|
||||||
|
- "setup-podman.sh"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
|
- "scripts/docker/setup.sh"
|
||||||
|
- "scripts/podman/setup.sh"
|
||||||
- "scripts/**/*docker*"
|
- "scripts/**/*docker*"
|
||||||
- "scripts/**/Dockerfile*"
|
- "scripts/**/Dockerfile*"
|
||||||
- "scripts/sandbox-*.sh"
|
- "scripts/sandbox-*.sh"
|
||||||
@ -290,6 +293,10 @@
|
|||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- "extensions/synthetic/**"
|
- "extensions/synthetic/**"
|
||||||
|
"extensions: tavily":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/tavily/**"
|
||||||
"extensions: talk-voice":
|
"extensions: talk-voice":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|||||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -11,7 +11,7 @@ Describe the problem and fix in 2–5 bullets:
|
|||||||
|
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
- [ ] Feature
|
- [ ] Feature
|
||||||
- [ ] Refactor
|
- [ ] Refactor required for the fix
|
||||||
- [ ] Docs
|
- [ ] Docs
|
||||||
- [ ] Security hardening
|
- [ ] Security hardening
|
||||||
- [ ] Chore/infra
|
- [ ] Chore/infra
|
||||||
|
|||||||
215
.github/workflows/ci.yml
vendored
215
.github/workflows/ci.yml
vendored
@ -215,26 +215,37 @@ jobs:
|
|||||||
- runtime: bun
|
- runtime: bun
|
||||||
task: test
|
task: test
|
||||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||||
|
- runtime: node
|
||||||
|
task: compat-node22
|
||||||
|
node_version: "22.x"
|
||||||
|
cache_key_suffix: "node22"
|
||||||
|
command: |
|
||||||
|
pnpm build
|
||||||
|
pnpm test
|
||||||
|
node scripts/stage-bundled-plugin-runtime-deps.mjs
|
||||||
|
node --import tsx scripts/release-check.ts
|
||||||
steps:
|
steps:
|
||||||
- name: Skip bun lane on pull requests
|
- name: Skip compatibility lanes on pull requests
|
||||||
if: github.event_name == 'pull_request' && matrix.runtime == 'bun'
|
if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
|
||||||
run: echo "Skipping Bun compatibility lane on pull requests."
|
run: echo "Skipping push-only lane on pull requests."
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
if: github.event_name != 'pull_request' || matrix.runtime != 'bun'
|
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: false
|
submodules: false
|
||||||
|
|
||||||
- name: Setup Node environment
|
- name: Setup Node environment
|
||||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||||
uses: ./.github/actions/setup-node-env
|
uses: ./.github/actions/setup-node-env
|
||||||
with:
|
with:
|
||||||
|
node-version: "${{ matrix.node_version || '24.x' }}"
|
||||||
|
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
|
||||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||||
use-sticky-disk: "false"
|
use-sticky-disk: "false"
|
||||||
|
|
||||||
- name: Configure Node test resources
|
- name: Configure Node test resources
|
||||||
if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
|
||||||
env:
|
env:
|
||||||
SHARD_COUNT: ${{ matrix.shard_count || '' }}
|
SHARD_COUNT: ${{ matrix.shard_count || '' }}
|
||||||
SHARD_INDEX: ${{ matrix.shard_index || '' }}
|
SHARD_INDEX: ${{ matrix.shard_index || '' }}
|
||||||
@ -249,11 +260,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
extension-fast:
|
extension-fast:
|
||||||
name: "extension-fast (${{ matrix.extension }})"
|
name: "extension-fast"
|
||||||
needs: [docs-scope, changed-scope, changed-extensions]
|
needs: [docs-scope, changed-scope, changed-extensions]
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
@ -301,11 +312,8 @@ jobs:
|
|||||||
- name: Strict TS build smoke
|
- name: Strict TS build smoke
|
||||||
run: pnpm build:strict-smoke
|
run: pnpm build:strict-smoke
|
||||||
|
|
||||||
- name: Enforce safe external URL opening policy
|
check-additional:
|
||||||
run: pnpm lint:ui:no-raw-window-open
|
name: "check-additional"
|
||||||
|
|
||||||
plugin-extension-boundary:
|
|
||||||
name: "plugin-extension-boundary"
|
|
||||||
needs: [docs-scope, changed-scope]
|
needs: [docs-scope, changed-scope]
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
@ -322,68 +330,71 @@ jobs:
|
|||||||
use-sticky-disk: "false"
|
use-sticky-disk: "false"
|
||||||
|
|
||||||
- name: Run plugin extension boundary guard
|
- name: Run plugin extension boundary guard
|
||||||
|
id: plugin_extension_boundary
|
||||||
|
continue-on-error: true
|
||||||
run: pnpm run lint:plugins:no-extension-imports
|
run: pnpm run lint:plugins:no-extension-imports
|
||||||
|
|
||||||
web-search-provider-boundary:
|
|
||||||
name: "web-search-provider-boundary"
|
|
||||||
needs: [docs-scope, changed-scope]
|
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Node environment
|
|
||||||
uses: ./.github/actions/setup-node-env
|
|
||||||
with:
|
|
||||||
install-bun: "false"
|
|
||||||
use-sticky-disk: "false"
|
|
||||||
|
|
||||||
- name: Run web search provider boundary guard
|
- name: Run web search provider boundary guard
|
||||||
|
id: web_search_provider_boundary
|
||||||
|
continue-on-error: true
|
||||||
run: pnpm run lint:web-search-provider-boundaries
|
run: pnpm run lint:web-search-provider-boundaries
|
||||||
|
|
||||||
extension-src-outside-plugin-sdk-boundary:
|
|
||||||
name: "extension-src-outside-plugin-sdk-boundary"
|
|
||||||
needs: [docs-scope, changed-scope]
|
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Node environment
|
|
||||||
uses: ./.github/actions/setup-node-env
|
|
||||||
with:
|
|
||||||
install-bun: "false"
|
|
||||||
use-sticky-disk: "false"
|
|
||||||
|
|
||||||
- name: Run extension src boundary guard
|
- name: Run extension src boundary guard
|
||||||
|
id: extension_src_outside_plugin_sdk_boundary
|
||||||
|
continue-on-error: true
|
||||||
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||||
|
|
||||||
extension-plugin-sdk-internal-boundary:
|
|
||||||
name: "extension-plugin-sdk-internal-boundary"
|
|
||||||
needs: [docs-scope, changed-scope]
|
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Node environment
|
|
||||||
uses: ./.github/actions/setup-node-env
|
|
||||||
with:
|
|
||||||
install-bun: "false"
|
|
||||||
use-sticky-disk: "false"
|
|
||||||
|
|
||||||
- name: Run extension plugin-sdk-internal guard
|
- name: Run extension plugin-sdk-internal guard
|
||||||
|
id: extension_plugin_sdk_internal_boundary
|
||||||
|
continue-on-error: true
|
||||||
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
||||||
|
|
||||||
|
- name: Enforce safe external URL opening policy
|
||||||
|
id: no_raw_window_open
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm lint:ui:no-raw-window-open
|
||||||
|
|
||||||
|
- name: Run gateway watch regression harness
|
||||||
|
id: gateway_watch_regression
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm test:gateway:watch-regression
|
||||||
|
|
||||||
|
- name: Upload gateway watch regression artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: gateway-watch-regression
|
||||||
|
path: .local/gateway-watch-regression/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Fail if any additional check failed
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
|
||||||
|
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
|
||||||
|
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
|
||||||
|
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||||
|
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||||
|
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||||
|
run: |
|
||||||
|
failures=0
|
||||||
|
for result in \
|
||||||
|
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
|
||||||
|
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||||
|
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
|
||||||
|
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||||
|
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||||
|
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||||
|
name="${result%%|*}"
|
||||||
|
outcome="${result#*|}"
|
||||||
|
if [ "$outcome" != "success" ]; then
|
||||||
|
echo "::error title=${name} failed::${name} outcome: ${outcome}"
|
||||||
|
failures=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit "$failures"
|
||||||
|
|
||||||
build-smoke:
|
build-smoke:
|
||||||
name: "build-smoke"
|
name: "build-smoke"
|
||||||
needs: [docs-scope, changed-scope]
|
needs: [docs-scope, changed-scope]
|
||||||
@ -416,34 +427,6 @@ jobs:
|
|||||||
- name: Check CLI startup memory
|
- name: Check CLI startup memory
|
||||||
run: pnpm test:startup:memory
|
run: pnpm test:startup:memory
|
||||||
|
|
||||||
gateway-watch-regression:
|
|
||||||
name: "gateway-watch-regression"
|
|
||||||
needs: [docs-scope, changed-scope]
|
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Node environment
|
|
||||||
uses: ./.github/actions/setup-node-env
|
|
||||||
with:
|
|
||||||
install-bun: "false"
|
|
||||||
use-sticky-disk: "false"
|
|
||||||
|
|
||||||
- name: Run gateway watch regression harness
|
|
||||||
run: pnpm test:gateway:watch-regression
|
|
||||||
|
|
||||||
- name: Upload gateway watch regression artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: gateway-watch-regression
|
|
||||||
path: .local/gateway-watch-regression/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||||
check-docs:
|
check-docs:
|
||||||
needs: [docs-scope]
|
needs: [docs-scope]
|
||||||
@ -464,43 +447,9 @@ jobs:
|
|||||||
- name: Check docs
|
- name: Check docs
|
||||||
run: pnpm check:docs
|
run: pnpm check:docs
|
||||||
|
|
||||||
compat-node22:
|
|
||||||
name: "compat-node22"
|
|
||||||
needs: [docs-scope, changed-scope]
|
|
||||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Node 22 compatibility environment
|
|
||||||
uses: ./.github/actions/setup-node-env
|
|
||||||
with:
|
|
||||||
node-version: "22.x"
|
|
||||||
cache-key-suffix: "node22"
|
|
||||||
install-bun: "false"
|
|
||||||
use-sticky-disk: "false"
|
|
||||||
|
|
||||||
- name: Configure Node 22 test resources
|
|
||||||
run: |
|
|
||||||
# Keep the compatibility lane aligned with the default Node test lane.
|
|
||||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
|
||||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build under Node 22
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Run tests under Node 22
|
|
||||||
run: pnpm test
|
|
||||||
|
|
||||||
- name: Verify npm pack under Node 22
|
|
||||||
run: pnpm release:check
|
|
||||||
|
|
||||||
skills-python:
|
skills-python:
|
||||||
needs: [docs-scope, changed-scope]
|
needs: [docs-scope, changed-scope]
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
|
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true')
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@ -970,10 +919,14 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- task: test
|
- task: test-play
|
||||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
|
||||||
- task: build
|
- task: test-third-party
|
||||||
command: ./gradlew --no-daemon :app:assembleDebug
|
command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
|
||||||
|
- task: build-play
|
||||||
|
command: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||||
|
- task: build-third-party
|
||||||
|
command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|||||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@ -116,7 +116,7 @@ jobs:
|
|||||||
- name: Build Android for CodeQL
|
- name: Build Android for CodeQL
|
||||||
if: matrix.language == 'java-kotlin'
|
if: matrix.language == 'java-kotlin'
|
||||||
working-directory: apps/android
|
working-directory: apps/android
|
||||||
run: ./gradlew --no-daemon :app:assembleDebug
|
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||||
|
|
||||||
- name: Build Swift for CodeQL
|
- name: Build Swift for CodeQL
|
||||||
if: matrix.language == 'swift'
|
if: matrix.language == 'swift'
|
||||||
|
|||||||
53
.github/workflows/install-smoke.yml
vendored
53
.github/workflows/install-smoke.yml
vendored
@ -62,24 +62,65 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||||
|
|
||||||
# This smoke only validates that the build-arg path preinstalls selected
|
# This smoke validates that the build-arg path preinstalls the matrix
|
||||||
# extension deps without breaking image build or basic CLI startup. It
|
# runtime deps declared by the plugin and that matrix discovery stays
|
||||||
# does not exercise runtime loading/registration of diagnostics-otel.
|
# healthy in the final runtime image.
|
||||||
- name: Build extension Dockerfile smoke image
|
- name: Build extension Dockerfile smoke image
|
||||||
uses: useblacksmith/build-push-action@v2
|
uses: useblacksmith/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
OPENCLAW_EXTENSIONS=matrix
|
||||||
tags: openclaw-ext-smoke:local
|
tags: openclaw-ext-smoke:local
|
||||||
load: true
|
load: true
|
||||||
push: false
|
push: false
|
||||||
provenance: false
|
provenance: false
|
||||||
|
|
||||||
- name: Smoke test Dockerfile with extension build arg
|
- name: Smoke test Dockerfile with matrix extension build arg
|
||||||
run: |
|
run: |
|
||||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
|
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||||
|
which openclaw &&
|
||||||
|
openclaw --version &&
|
||||||
|
node -e "
|
||||||
|
const Module = require(\"node:module\");
|
||||||
|
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
|
||||||
|
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||||
|
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
|
||||||
|
if (runtimeDeps.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const dep of runtimeDeps) {
|
||||||
|
requireFromMatrix.resolve(dep);
|
||||||
|
}
|
||||||
|
const { spawnSync } = require(\"node:child_process\");
|
||||||
|
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||||
|
if (run.status !== 0) {
|
||||||
|
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
|
||||||
|
process.exit(run.status ?? 1);
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(run.stdout);
|
||||||
|
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
|
||||||
|
if (!matrix) {
|
||||||
|
throw new Error(\"matrix plugin missing from bundled plugin list\");
|
||||||
|
}
|
||||||
|
const matrixDiag = (parsed.diagnostics || []).filter(
|
||||||
|
(diag) =>
|
||||||
|
typeof diag.source === \"string\" &&
|
||||||
|
diag.source.includes(\"/extensions/matrix\") &&
|
||||||
|
typeof diag.message === \"string\" &&
|
||||||
|
diag.message.includes(\"extension entry escapes package directory\"),
|
||||||
|
);
|
||||||
|
if (matrixDiag.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
\"unexpected matrix diagnostics: \" +
|
||||||
|
matrixDiag.map((diag) => diag.message).join(\"; \"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
'
|
||||||
|
|
||||||
- name: Build installer smoke image
|
- name: Build installer smoke image
|
||||||
uses: useblacksmith/build-push-action@v2
|
uses: useblacksmith/build-push-action@v2
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -31,6 +31,7 @@ apps/android/.gradle/
|
|||||||
apps/android/app/build/
|
apps/android/app/build/
|
||||||
apps/android/.cxx/
|
apps/android/.cxx/
|
||||||
apps/android/.kotlin/
|
apps/android/.kotlin/
|
||||||
|
apps/android/benchmark/results/
|
||||||
|
|
||||||
# Bun build artifacts
|
# Bun build artifacts
|
||||||
*.bun-build
|
*.bun-build
|
||||||
@ -100,8 +101,6 @@ USER.md
|
|||||||
/local/
|
/local/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.claude/
|
.claude/
|
||||||
.agents/
|
|
||||||
.agents
|
|
||||||
.agent/
|
.agent/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
@ -135,3 +134,6 @@ ui/src/ui/__screenshots__
|
|||||||
ui/src/ui/views/__screenshots__
|
ui/src/ui/views/__screenshots__
|
||||||
ui/.vitest-attachments
|
ui/.vitest-attachments
|
||||||
docs/superpowers
|
docs/superpowers
|
||||||
|
|
||||||
|
# Deprecated changelog fragment workflow
|
||||||
|
changelog/fragments/
|
||||||
|
|||||||
199
AGENTS.md
199
AGENTS.md
@ -2,52 +2,17 @@
|
|||||||
|
|
||||||
- Repo: https://github.com/openclaw/openclaw
|
- Repo: https://github.com/openclaw/openclaw
|
||||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
|
||||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
|
||||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
|
||||||
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
|
|
||||||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
|
||||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
|
||||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
|
||||||
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
||||||
|
|
||||||
## Auto-close labels (issues and PRs)
|
|
||||||
|
|
||||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
|
||||||
- Do not manually close + manually comment for these reasons.
|
|
||||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
|
||||||
- `r:*` labels can be used on both issues and PRs.
|
|
||||||
|
|
||||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
|
||||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
|
||||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
|
||||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
|
||||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
|
||||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
|
||||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
|
||||||
- `r: spam`: close + lock as spam (`lock_reason: spam`).
|
|
||||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
|
||||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
|
||||||
|
|
||||||
## PR truthfulness and bug-fix validation
|
|
||||||
|
|
||||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
|
||||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
|
||||||
- Minimum merge gate for bug-fix PRs:
|
|
||||||
1. symptom evidence (repro/log/failing test),
|
|
||||||
2. verified root cause in code with file/line,
|
|
||||||
3. fix touches the implicated code path,
|
|
||||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
|
||||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
|
||||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename.
|
||||||
|
- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
||||||
|
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
|
||||||
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||||
- Core channel docs: `docs/channels/`
|
- Core channel docs: `docs/channels/`
|
||||||
@ -106,15 +71,24 @@
|
|||||||
- Format check: `pnpm format` (oxfmt --check)
|
- Format check: `pnpm format` (oxfmt --check)
|
||||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
|
||||||
|
- Preferred landing bar for pushes to `main`: `pnpm check` and `pnpm test`, with a green result when feasible.
|
||||||
|
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
|
||||||
|
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
|
||||||
|
- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface.
|
||||||
|
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
|
||||||
|
- Do not use scoped tests as permission to ignore plausibly related failures.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
- Formatting/linting via Oxlint and Oxfmt.
|
||||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||||
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
||||||
|
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
|
||||||
|
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
|
||||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||||
@ -124,20 +98,21 @@
|
|||||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||||
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
||||||
|
|
||||||
## Release Channels (Naming)
|
## Release / Advisory Workflows
|
||||||
|
|
||||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
|
||||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
|
||||||
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
|
- Release and publish remain explicit-approval actions even when using the skill.
|
||||||
- dev: moving head on `main` (no tag; git checkout main).
|
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
|
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||||
- Do not set test workers above 16; tried already.
|
- Do not set test workers above 16; tried already.
|
||||||
|
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
|
||||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||||
@ -149,7 +124,9 @@
|
|||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
|
||||||
|
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
|
||||||
|
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
|
||||||
|
|
||||||
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
||||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||||
@ -158,105 +135,30 @@
|
|||||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||||
|
|
||||||
## Shorthand Commands
|
|
||||||
|
|
||||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
|
||||||
|
|
||||||
## Git Notes
|
## Git Notes
|
||||||
|
|
||||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||||
|
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
|
||||||
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
|
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
|
||||||
|
|
||||||
## GitHub Search (`gh`)
|
|
||||||
|
|
||||||
- Prefer targeted keyword search before proposing new work or duplicating fixes.
|
|
||||||
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
|
|
||||||
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
|
||||||
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
|
||||||
- Structured output example:
|
|
||||||
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
|
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
|
|
||||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||||
- Environment variables: see `~/.profile`.
|
- Environment variables: see `~/.profile`.
|
||||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
|
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
|
||||||
|
|
||||||
## GHSA (Repo Advisory) Patch/Publish
|
## Local Runtime / Platform Notes
|
||||||
|
|
||||||
- Before reviewing security advisories, read `SECURITY.md`.
|
|
||||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
|
||||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
|
||||||
- Private fork PRs must be closed:
|
|
||||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
|
||||||
`gh pr list -R "$fork" --state open` (must be empty)
|
|
||||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
|
||||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
|
||||||
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
|
|
||||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
|
||||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
|
||||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
|
||||||
|
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Vocabulary: "makeup" = "mac app".
|
||||||
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
|
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||||
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
|
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
|
||||||
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
|
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
|
||||||
- Parallels macOS smoke playbook:
|
|
||||||
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
|
|
||||||
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
|
|
||||||
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
|
||||||
- Discord roundtrip smoke is opt-in. Pass `--discord-token-env <VAR> --discord-guild-id <guild> --discord-channel-id <channel>`; the harness will configure Discord in-guest, post a guest message, verify host-side visibility via the Discord REST API, post a fresh host-side message back into the channel, then verify `openclaw message read` sees it in-guest.
|
|
||||||
- Keep the Discord token in a host env var only. For Peter’s Mac Studio bot, fetch it into a temp env var from `~/.openclaw/openclaw.json` over SSH instead of hardcoding it in repo files/shell history.
|
|
||||||
- For Discord smoke on this snapshot: use `openclaw message send/read` via the installed wrapper, not `node openclaw.mjs message ...`; lazy `message` subcommands do not resolve the same way through the direct module entrypoint.
|
|
||||||
- For Discord guild allowlists: set `channels.discord.guilds` as one JSON object. Do not use dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated as array indexes.
|
|
||||||
- Avoid `prlctl enter` / expect for the Discord config phase; long lines get mangled. Use `prlctl exec --current-user /bin/sh -lc ...` with short commands or temp files.
|
|
||||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
|
||||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
|
||||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
|
|
||||||
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
|
|
||||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
|
||||||
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
|
||||||
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
|
|
||||||
- Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
|
|
||||||
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
|
|
||||||
- Parallels Windows smoke playbook:
|
|
||||||
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
|
||||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
|
||||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
|
||||||
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
|
|
||||||
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
|
|
||||||
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
|
|
||||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
|
|
||||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
|
||||||
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
|
|
||||||
- Parallels Linux smoke playbook:
|
|
||||||
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
|
||||||
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
|
|
||||||
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
|
|
||||||
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
|
|
||||||
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
|
|
||||||
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
|
|
||||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
|
|
||||||
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
|
|
||||||
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
|
|
||||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
|
|
||||||
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
|
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||||
|
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
|
||||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
|
||||||
- Never update the Carbon dependency.
|
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
|
||||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
@ -271,6 +173,20 @@
|
|||||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||||
|
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||||
|
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||||
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
|
- Voice wake forwarding tips:
|
||||||
|
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||||
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||||
|
|
||||||
|
## Collaboration / Safety Notes
|
||||||
|
|
||||||
|
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||||
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
|
- Never update the Carbon dependency.
|
||||||
|
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||||
|
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||||
@ -281,41 +197,12 @@
|
|||||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||||
- Only ask when changes are semantic (logic/data/behavior).
|
- Only ask when changes are semantic (logic/data/behavior).
|
||||||
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
|
||||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
|
||||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
|
||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
|
||||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||||
|
|
||||||
## Release Auth
|
|
||||||
|
|
||||||
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
|
||||||
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
|
|
||||||
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
|
|
||||||
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
|
|
||||||
|
|
||||||
## Changelog Release Notes
|
|
||||||
|
|
||||||
- When cutting a mac release with beta GitHub prerelease:
|
|
||||||
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
|
|
||||||
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
|
|
||||||
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
|
|
||||||
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
|
|
||||||
|
|
||||||
- Keep top version entries in `CHANGELOG.md` sorted by impact:
|
|
||||||
- `### Changes` first.
|
|
||||||
- `### Fixes` deduped and ranked with user-facing fixes first.
|
|
||||||
- Before tagging/publishing, run:
|
|
||||||
- `node --import tsx scripts/release-check.ts`
|
|
||||||
- `pnpm release:check`
|
|
||||||
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
|
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
|
- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
|
||||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||||
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
|
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
|
||||||
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
|
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
|
||||||
@ -23,7 +24,8 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
|
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
|
||||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
|
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
|
||||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
|
||||||
|
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
|
||||||
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
||||||
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
||||||
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
||||||
@ -43,13 +45,26 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
||||||
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
||||||
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
||||||
|
- Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao.
|
||||||
|
- Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849)
|
||||||
|
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
|
||||||
|
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
|
||||||
|
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
|
||||||
|
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
|
||||||
|
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
||||||
|
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
|
||||||
|
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
|
||||||
|
- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
|
||||||
|
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||||
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
||||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
||||||
|
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
|
||||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||||
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
|
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
|
||||||
@ -74,6 +89,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||||
|
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
|
||||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||||
@ -89,6 +105,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
|
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
|
||||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||||
|
- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16.
|
||||||
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
|
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
|
||||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
|
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
|
||||||
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
|
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
|
||||||
@ -106,6 +123,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||||
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
||||||
|
- Gateway/agent events: stop broadcasting false end-of-run `seq gap` errors to clients, and isolate node-driven ingress turns with per-turn run IDs so stale tail events cannot leak into later session runs. (#43751) Thanks @caesargattuso.
|
||||||
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
|
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
|
||||||
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
|
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
|
||||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||||
@ -114,6 +132,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
|
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
|
||||||
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
|
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
|
||||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
|
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
|
||||||
|
- Gateway/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev.
|
||||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
|
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
|
||||||
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
||||||
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
||||||
@ -130,6 +149,15 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
|
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
|
||||||
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
|
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
|
||||||
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
|
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
|
||||||
|
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
|
||||||
|
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
|
||||||
|
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
|
||||||
|
- Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant.
|
||||||
|
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
|
||||||
|
- Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
|
||||||
|
- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
|
||||||
|
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
|
||||||
|
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
@ -150,6 +178,23 @@ Docs: https://docs.openclaw.ai
|
|||||||
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
|
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
|
||||||
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
|
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
|
||||||
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
|
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
|
||||||
|
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
|
||||||
|
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
|
||||||
|
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
|
||||||
|
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
|
||||||
|
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
|
||||||
|
- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus.
|
||||||
|
- Plugins/update: let `openclaw plugins update <npm-spec>` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo.
|
||||||
|
- Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo.
|
||||||
|
- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo.
|
||||||
|
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
|
||||||
|
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
|
||||||
|
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
||||||
|
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
|
||||||
|
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
|
||||||
|
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
|
||||||
|
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
|
||||||
|
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
@ -161,6 +206,9 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
||||||
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
|
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
|
||||||
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
|
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
|
||||||
|
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
|
||||||
|
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
|
||||||
|
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
|
||||||
|
|
||||||
## 2026.3.13
|
## 2026.3.13
|
||||||
|
|
||||||
@ -211,6 +259,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||||
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
||||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||||
|
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
|
||||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||||
@ -237,6 +286,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
||||||
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
|
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
|
||||||
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
|
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
|
||||||
|
- Sessions/BlueBubbles/cron: persist outbound session routing and transcript mirroring for new targets, auto-create BlueBubbles chats before attachment sends, and only suppress isolated cron deliveries when the run started hours late instead of merely finishing late. (#50092)
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
@ -83,8 +83,9 @@ Welcome to the lobster tank! 🦞
|
|||||||
|
|
||||||
1. **Bugs & small fixes** → Open a PR!
|
1. **Bugs & small fixes** → Open a PR!
|
||||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
|
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
|
||||||
3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first.
|
3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
|
||||||
4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||||
|
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||||
|
|
||||||
## Before You PR
|
## Before You PR
|
||||||
|
|
||||||
@ -97,7 +98,9 @@ Welcome to the lobster tank! 🦞
|
|||||||
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
|
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
|
||||||
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
||||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||||
|
- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.
|
||||||
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
|
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
|
||||||
|
- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR.
|
||||||
- Ensure CI checks pass
|
- Ensure CI checks pass
|
||||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||||
- Describe what & why
|
- Describe what & why
|
||||||
|
|||||||
@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
|
|||||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||||
|
|
||||||
|
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||||
|
# bundled discovery so package metadata that points at source entries stays valid.
|
||||||
|
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
|
||||||
|
|
||||||
# Keep pnpm available in the runtime image for container-local workflows.
|
# Keep pnpm available in the runtime image for container-local workflows.
|
||||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||||
# first-run network fetch when invoking pnpm.
|
# first-run network fetch when invoking pnpm.
|
||||||
|
|||||||
@ -49,7 +49,7 @@ Model note: while many providers/models are supported, for the best experience a
|
|||||||
|
|
||||||
## Install (recommended)
|
## Install (recommended)
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g openclaw@latest
|
npm install -g openclaw@latest
|
||||||
@ -62,7 +62,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
|||||||
|
|
||||||
## Quick start (TL;DR)
|
## Quick start (TL;DR)
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||||
|
|
||||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||||
|
|
||||||
|
|||||||
@ -27,14 +27,34 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/android
|
cd apps/android
|
||||||
./gradlew :app:assembleDebug
|
./gradlew :app:assemblePlayDebug
|
||||||
./gradlew :app:installDebug
|
./gradlew :app:installPlayDebug
|
||||||
./gradlew :app:testDebugUnitTest
|
./gradlew :app:testPlayDebugUnitTest
|
||||||
cd ../..
|
cd ../..
|
||||||
bun run android:bundle:release
|
bun run android:bundle:release
|
||||||
```
|
```
|
||||||
|
|
||||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`.
|
Third-party debug flavor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/android
|
||||||
|
./gradlew :app:assembleThirdPartyDebug
|
||||||
|
./gradlew :app:installThirdPartyDebug
|
||||||
|
./gradlew :app:testThirdPartyDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
|
||||||
|
|
||||||
|
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
|
||||||
|
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
|
||||||
|
|
||||||
|
Flavor-specific direct Gradle tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/android
|
||||||
|
./gradlew :app:bundlePlayRelease
|
||||||
|
./gradlew :app:bundleThirdPartyRelease
|
||||||
|
```
|
||||||
|
|
||||||
## Kotlin Lint + Format
|
## Kotlin Lint + Format
|
||||||
|
|
||||||
@ -176,6 +196,48 @@ More details: `docs/platforms/android.md`.
|
|||||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||||
|
|
||||||
|
## Google Play Restricted Permissions
|
||||||
|
|
||||||
|
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
|
||||||
|
|
||||||
|
- `READ_SMS`
|
||||||
|
- `SEND_SMS`
|
||||||
|
- `READ_CALL_LOG`
|
||||||
|
|
||||||
|
Why these matter:
|
||||||
|
|
||||||
|
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||||
|
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||||
|
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||||
|
|
||||||
|
Current OpenClaw Android implication:
|
||||||
|
|
||||||
|
- APK / sideload build can keep SMS and Call Log features.
|
||||||
|
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||||
|
- The repo now ships this split as Android product flavors:
|
||||||
|
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||||
|
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||||
|
|
||||||
|
Policy links:
|
||||||
|
|
||||||
|
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
|
||||||
|
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
|
||||||
|
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
|
||||||
|
|
||||||
|
Other Play-restricted surfaces to watch if added later:
|
||||||
|
|
||||||
|
- `ACCESS_BACKGROUND_LOCATION`
|
||||||
|
- `MANAGE_EXTERNAL_STORAGE`
|
||||||
|
- `QUERY_ALL_PACKAGES`
|
||||||
|
- `REQUEST_INSTALL_PACKAGES`
|
||||||
|
- `AccessibilityService`
|
||||||
|
|
||||||
|
Reference links:
|
||||||
|
|
||||||
|
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
|
||||||
|
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
|
||||||
|
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
|
||||||
|
|
||||||
## Integration Capability Test (Preconditioned)
|
## Integration Capability Test (Preconditioned)
|
||||||
|
|
||||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||||
|
|||||||
@ -65,14 +65,29 @@ android {
|
|||||||
applicationId = "ai.openclaw.app"
|
applicationId = "ai.openclaw.app"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2026031400
|
versionCode = 2026032000
|
||||||
versionName = "2026.3.14"
|
versionName = "2026.3.20"
|
||||||
ndk {
|
ndk {
|
||||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flavorDimensions += "store"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("play") {
|
||||||
|
dimension = "store"
|
||||||
|
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
|
||||||
|
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
|
||||||
|
}
|
||||||
|
create("thirdParty") {
|
||||||
|
dimension = "store"
|
||||||
|
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
|
||||||
|
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
if (hasAndroidReleaseSigning) {
|
if (hasAndroidReleaseSigning) {
|
||||||
@ -140,8 +155,13 @@ androidComponents {
|
|||||||
.forEach { output ->
|
.forEach { output ->
|
||||||
val versionName = output.versionName.orNull ?: "0"
|
val versionName = output.versionName.orNull ?: "0"
|
||||||
val buildType = variant.buildType
|
val buildType = variant.buildType
|
||||||
|
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
|
||||||
val outputFileName = "openclaw-$versionName-$buildType.apk"
|
val outputFileName =
|
||||||
|
if (flavorName == null) {
|
||||||
|
"openclaw-$versionName-$buildType.apk"
|
||||||
|
} else {
|
||||||
|
"openclaw-$versionName-$flavorName-$buildType.apk"
|
||||||
|
}
|
||||||
output.outputFileName = outputFileName
|
output.outputFileName = outputFileName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
|
|||||||
@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
foreground = value
|
foreground = value
|
||||||
runtimeRef.value?.setForeground(value)
|
val runtime =
|
||||||
|
if (value && prefs.onboardingCompleted.value) {
|
||||||
|
ensureRuntime()
|
||||||
|
} else {
|
||||||
|
runtimeRef.value
|
||||||
|
}
|
||||||
|
runtime?.setForeground(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
fun setDisplayName(value: String) {
|
||||||
|
|||||||
@ -89,6 +89,8 @@ class NodeRuntime(
|
|||||||
|
|
||||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||||
appContext = appContext,
|
appContext = appContext,
|
||||||
|
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||||
|
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||||
@ -137,7 +139,9 @@ class NodeRuntime(
|
|||||||
voiceWakeMode = { VoiceWakeMode.Off },
|
voiceWakeMode = { VoiceWakeMode.Off },
|
||||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||||
smsAvailable = { sms.canSendSms() },
|
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||||
|
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||||
|
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||||
manualTls = { manualTls.value },
|
manualTls = { manualTls.value },
|
||||||
)
|
)
|
||||||
@ -160,7 +164,9 @@ class NodeRuntime(
|
|||||||
isForeground = { _isForeground.value },
|
isForeground = { _isForeground.value },
|
||||||
cameraEnabled = { cameraEnabled.value },
|
cameraEnabled = { cameraEnabled.value },
|
||||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||||
smsAvailable = { sms.canSendSms() },
|
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||||
|
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||||
|
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||||
debugBuild = { BuildConfig.DEBUG },
|
debugBuild = { BuildConfig.DEBUG },
|
||||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||||
onCanvasA2uiPush = {
|
onCanvasA2uiPush = {
|
||||||
@ -566,43 +572,8 @@ class NodeRuntime(
|
|||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
gateways.collect { list ->
|
gateways.collect { list ->
|
||||||
if (list.isNotEmpty()) {
|
seedLastDiscoveredGateway(list)
|
||||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
autoConnectIfNeeded()
|
||||||
// UX parity with iOS: only set once when unset.
|
|
||||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
|
||||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didAutoConnect) return@collect
|
|
||||||
if (_isConnected.value) return@collect
|
|
||||||
|
|
||||||
if (manualEnabled.value) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
return@collect
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,11 +598,53 @@ class NodeRuntime(
|
|||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
_isForeground.value = value
|
_isForeground.value = value
|
||||||
if (!value) {
|
if (value) {
|
||||||
|
reconnectPreferredGatewayOnForeground()
|
||||||
|
} else {
|
||||||
stopActiveVoiceSession()
|
stopActiveVoiceSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
|
||||||
|
if (list.isEmpty()) return
|
||||||
|
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
|
||||||
|
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||||
|
if (manualEnabled.value) {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isEmpty() || port !in 1..65535) return null
|
||||||
|
return GatewayEndpoint.manual(host = host, port = port)
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||||
|
if (targetStableId.isEmpty()) return null
|
||||||
|
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
|
||||||
|
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||||
|
if (storedFingerprint.isEmpty()) return null
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoConnectIfNeeded() {
|
||||||
|
if (didAutoConnect) return
|
||||||
|
if (_isConnected.value) return
|
||||||
|
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectPreferredGatewayOnForeground() {
|
||||||
|
if (_isConnected.value) return
|
||||||
|
if (_pendingGatewayTrust.value != null) return
|
||||||
|
if (connectedEndpoint != null) {
|
||||||
|
refreshGatewayConnection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||||
|
}
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
fun setDisplayName(value: String) {
|
||||||
prefs.setDisplayName(value)
|
prefs.setDisplayName(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class ChatController(
|
|||||||
fun load(sessionKey: String) {
|
fun load(sessionKey: String) {
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
val key = sessionKey.trim().ifEmpty { "main" }
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
fun applyMainSessionKey(mainSessionKey: String) {
|
||||||
@ -84,11 +84,11 @@ class ChatController(
|
|||||||
if (_sessionKey.value == trimmed) return
|
if (_sessionKey.value == trimmed) return
|
||||||
if (_sessionKey.value != "main") return
|
if (_sessionKey.value != "main") return
|
||||||
_sessionKey.value = trimmed
|
_sessionKey.value = trimmed
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSessions(limit: Int? = null) {
|
fun refreshSessions(limit: Int? = null) {
|
||||||
@ -106,7 +106,9 @@ class ChatController(
|
|||||||
if (key.isEmpty()) return
|
if (key.isEmpty()) return
|
||||||
if (key == _sessionKey.value) return
|
if (key == _sessionKey.value) return
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
// Keep the thread switch path lean: history + health are needed immediately,
|
||||||
|
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||||
|
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
@ -249,7 +251,7 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
|
||||||
_errorText.value = null
|
_errorText.value = null
|
||||||
_healthOk.value = false
|
_healthOk.value = false
|
||||||
clearPendingRuns()
|
clearPendingRuns()
|
||||||
@ -271,7 +273,9 @@ class ChatController(
|
|||||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||||
|
|
||||||
pollHealthIfNeeded(force = forceHealth)
|
pollHealthIfNeeded(force = forceHealth)
|
||||||
|
if (refreshSessions) {
|
||||||
fetchSessions(limit = 50)
|
fetchSessions(limit = 50)
|
||||||
|
}
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
_errorText.value = err.message
|
_errorText.value = err.message
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,9 @@ class ConnectionManager(
|
|||||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||||
private val motionActivityAvailable: () -> Boolean,
|
private val motionActivityAvailable: () -> Boolean,
|
||||||
private val motionPedometerAvailable: () -> Boolean,
|
private val motionPedometerAvailable: () -> Boolean,
|
||||||
private val smsAvailable: () -> Boolean,
|
private val sendSmsAvailable: () -> Boolean,
|
||||||
|
private val readSmsAvailable: () -> Boolean,
|
||||||
|
private val callLogAvailable: () -> Boolean,
|
||||||
private val hasRecordAudioPermission: () -> Boolean,
|
private val hasRecordAudioPermission: () -> Boolean,
|
||||||
private val manualTls: () -> Boolean,
|
private val manualTls: () -> Boolean,
|
||||||
) {
|
) {
|
||||||
@ -78,7 +80,9 @@ class ConnectionManager(
|
|||||||
NodeRuntimeFlags(
|
NodeRuntimeFlags(
|
||||||
cameraEnabled = cameraEnabled(),
|
cameraEnabled = cameraEnabled(),
|
||||||
locationEnabled = locationMode() != LocationMode.Off,
|
locationEnabled = locationMode() != LocationMode.Off,
|
||||||
smsAvailable = smsAvailable(),
|
sendSmsAvailable = sendSmsAvailable(),
|
||||||
|
readSmsAvailable = readSmsAvailable(),
|
||||||
|
callLogAvailable = callLogAvailable(),
|
||||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||||
motionActivityAvailable = motionActivityAvailable(),
|
motionActivityAvailable = motionActivityAvailable(),
|
||||||
motionPedometerAvailable = motionPedometerAvailable(),
|
motionPedometerAvailable = motionPedometerAvailable(),
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import kotlinx.serialization.json.put
|
|||||||
|
|
||||||
class DeviceHandler(
|
class DeviceHandler(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
|
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||||
|
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||||
) {
|
) {
|
||||||
private data class BatterySnapshot(
|
private data class BatterySnapshot(
|
||||||
val status: Int,
|
val status: Int,
|
||||||
@ -173,8 +175,8 @@ class DeviceHandler(
|
|||||||
put(
|
put(
|
||||||
"sms",
|
"sms",
|
||||||
permissionStateJson(
|
permissionStateJson(
|
||||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||||
promptableWhenDenied = canSendSms,
|
promptableWhenDenied = smsEnabled && canSendSms,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
@ -215,8 +217,8 @@ class DeviceHandler(
|
|||||||
put(
|
put(
|
||||||
"callLog",
|
"callLog",
|
||||||
permissionStateJson(
|
permissionStateJson(
|
||||||
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
|
granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||||
promptableWhenDenied = true,
|
promptableWhenDenied = callLogEnabled,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
|
|||||||
@ -18,7 +18,9 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
|
|||||||
data class NodeRuntimeFlags(
|
data class NodeRuntimeFlags(
|
||||||
val cameraEnabled: Boolean,
|
val cameraEnabled: Boolean,
|
||||||
val locationEnabled: Boolean,
|
val locationEnabled: Boolean,
|
||||||
val smsAvailable: Boolean,
|
val sendSmsAvailable: Boolean,
|
||||||
|
val readSmsAvailable: Boolean,
|
||||||
|
val callLogAvailable: Boolean,
|
||||||
val voiceWakeEnabled: Boolean,
|
val voiceWakeEnabled: Boolean,
|
||||||
val motionActivityAvailable: Boolean,
|
val motionActivityAvailable: Boolean,
|
||||||
val motionPedometerAvailable: Boolean,
|
val motionPedometerAvailable: Boolean,
|
||||||
@ -29,7 +31,9 @@ enum class InvokeCommandAvailability {
|
|||||||
Always,
|
Always,
|
||||||
CameraEnabled,
|
CameraEnabled,
|
||||||
LocationEnabled,
|
LocationEnabled,
|
||||||
SmsAvailable,
|
SendSmsAvailable,
|
||||||
|
ReadSmsAvailable,
|
||||||
|
CallLogAvailable,
|
||||||
MotionActivityAvailable,
|
MotionActivityAvailable,
|
||||||
MotionPedometerAvailable,
|
MotionPedometerAvailable,
|
||||||
DebugBuild,
|
DebugBuild,
|
||||||
@ -40,6 +44,7 @@ enum class NodeCapabilityAvailability {
|
|||||||
CameraEnabled,
|
CameraEnabled,
|
||||||
LocationEnabled,
|
LocationEnabled,
|
||||||
SmsAvailable,
|
SmsAvailable,
|
||||||
|
CallLogAvailable,
|
||||||
VoiceWakeEnabled,
|
VoiceWakeEnabled,
|
||||||
MotionAvailable,
|
MotionAvailable,
|
||||||
}
|
}
|
||||||
@ -85,7 +90,10 @@ object InvokeCommandRegistry {
|
|||||||
name = OpenClawCapability.Motion.rawValue,
|
name = OpenClawCapability.Motion.rawValue,
|
||||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||||
),
|
),
|
||||||
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
|
NodeCapabilitySpec(
|
||||||
|
name = OpenClawCapability.CallLog.rawValue,
|
||||||
|
availability = NodeCapabilityAvailability.CallLogAvailable,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val all: List<InvokeCommandSpec> =
|
val all: List<InvokeCommandSpec> =
|
||||||
@ -187,10 +195,15 @@ object InvokeCommandRegistry {
|
|||||||
),
|
),
|
||||||
InvokeCommandSpec(
|
InvokeCommandSpec(
|
||||||
name = OpenClawSmsCommand.Send.rawValue,
|
name = OpenClawSmsCommand.Send.rawValue,
|
||||||
availability = InvokeCommandAvailability.SmsAvailable,
|
availability = InvokeCommandAvailability.SendSmsAvailable,
|
||||||
|
),
|
||||||
|
InvokeCommandSpec(
|
||||||
|
name = OpenClawSmsCommand.Search.rawValue,
|
||||||
|
availability = InvokeCommandAvailability.ReadSmsAvailable,
|
||||||
),
|
),
|
||||||
InvokeCommandSpec(
|
InvokeCommandSpec(
|
||||||
name = OpenClawCallLogCommand.Search.rawValue,
|
name = OpenClawCallLogCommand.Search.rawValue,
|
||||||
|
availability = InvokeCommandAvailability.CallLogAvailable,
|
||||||
),
|
),
|
||||||
InvokeCommandSpec(
|
InvokeCommandSpec(
|
||||||
name = "debug.logs",
|
name = "debug.logs",
|
||||||
@ -213,7 +226,8 @@ object InvokeCommandRegistry {
|
|||||||
NodeCapabilityAvailability.Always -> true
|
NodeCapabilityAvailability.Always -> true
|
||||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||||
|
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||||
}
|
}
|
||||||
@ -228,7 +242,9 @@ object InvokeCommandRegistry {
|
|||||||
InvokeCommandAvailability.Always -> true
|
InvokeCommandAvailability.Always -> true
|
||||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
|
||||||
|
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||||
|
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||||
|
|||||||
@ -32,7 +32,9 @@ class InvokeDispatcher(
|
|||||||
private val isForeground: () -> Boolean,
|
private val isForeground: () -> Boolean,
|
||||||
private val cameraEnabled: () -> Boolean,
|
private val cameraEnabled: () -> Boolean,
|
||||||
private val locationEnabled: () -> Boolean,
|
private val locationEnabled: () -> Boolean,
|
||||||
private val smsAvailable: () -> Boolean,
|
private val sendSmsAvailable: () -> Boolean,
|
||||||
|
private val readSmsAvailable: () -> Boolean,
|
||||||
|
private val callLogAvailable: () -> Boolean,
|
||||||
private val debugBuild: () -> Boolean,
|
private val debugBuild: () -> Boolean,
|
||||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||||
private val onCanvasA2uiPush: () -> Unit,
|
private val onCanvasA2uiPush: () -> Unit,
|
||||||
@ -162,6 +164,7 @@ class InvokeDispatcher(
|
|||||||
|
|
||||||
// SMS command
|
// SMS command
|
||||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||||
|
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
|
||||||
|
|
||||||
// CallLog command
|
// CallLog command
|
||||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||||
@ -256,8 +259,8 @@ class InvokeDispatcher(
|
|||||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
InvokeCommandAvailability.SmsAvailable ->
|
InvokeCommandAvailability.SendSmsAvailable ->
|
||||||
if (smsAvailable()) {
|
if (sendSmsAvailable()) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
GatewaySession.InvokeResult.error(
|
GatewaySession.InvokeResult.error(
|
||||||
@ -265,6 +268,24 @@ class InvokeDispatcher(
|
|||||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
InvokeCommandAvailability.ReadSmsAvailable ->
|
||||||
|
if (readSmsAvailable()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
GatewaySession.InvokeResult.error(
|
||||||
|
code = "SMS_UNAVAILABLE",
|
||||||
|
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InvokeCommandAvailability.CallLogAvailable ->
|
||||||
|
if (callLogAvailable()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
GatewaySession.InvokeResult.error(
|
||||||
|
code = "CALL_LOG_UNAVAILABLE",
|
||||||
|
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||||
|
)
|
||||||
|
}
|
||||||
InvokeCommandAvailability.DebugBuild ->
|
InvokeCommandAvailability.DebugBuild ->
|
||||||
if (debugBuild()) {
|
if (debugBuild()) {
|
||||||
null
|
null
|
||||||
|
|||||||
@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat
|
|||||||
import ai.openclaw.app.gateway.GatewaySession
|
import ai.openclaw.app.gateway.GatewaySession
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
class LocationHandler(
|
internal interface LocationDataSource {
|
||||||
|
fun hasFinePermission(context: Context): Boolean
|
||||||
|
|
||||||
|
fun hasCoarsePermission(context: Context): Boolean
|
||||||
|
|
||||||
|
suspend fun fetchLocation(
|
||||||
|
desiredProviders: List<String>,
|
||||||
|
maxAgeMs: Long?,
|
||||||
|
timeoutMs: Long,
|
||||||
|
isPrecise: Boolean,
|
||||||
|
): LocationCaptureManager.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DefaultLocationDataSource(
|
||||||
|
private val capture: LocationCaptureManager,
|
||||||
|
) : LocationDataSource {
|
||||||
|
override fun hasFinePermission(context: Context): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
override fun hasCoarsePermission(context: Context): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
override suspend fun fetchLocation(
|
||||||
|
desiredProviders: List<String>,
|
||||||
|
maxAgeMs: Long?,
|
||||||
|
timeoutMs: Long,
|
||||||
|
isPrecise: Boolean,
|
||||||
|
): LocationCaptureManager.Payload =
|
||||||
|
capture.getLocation(
|
||||||
|
desiredProviders = desiredProviders,
|
||||||
|
maxAgeMs = maxAgeMs,
|
||||||
|
timeoutMs = timeoutMs,
|
||||||
|
isPrecise = isPrecise,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocationHandler private constructor(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
private val location: LocationCaptureManager,
|
private val dataSource: LocationDataSource,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
private val isForeground: () -> Boolean,
|
private val isForeground: () -> Boolean,
|
||||||
private val locationPreciseEnabled: () -> Boolean,
|
private val locationPreciseEnabled: () -> Boolean,
|
||||||
) {
|
) {
|
||||||
fun hasFineLocationPermission(): Boolean {
|
constructor(
|
||||||
return (
|
appContext: Context,
|
||||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
location: LocationCaptureManager,
|
||||||
PackageManager.PERMISSION_GRANTED
|
json: Json,
|
||||||
|
isForeground: () -> Boolean,
|
||||||
|
locationPreciseEnabled: () -> Boolean,
|
||||||
|
) : this(
|
||||||
|
appContext = appContext,
|
||||||
|
dataSource = DefaultLocationDataSource(location),
|
||||||
|
json = json,
|
||||||
|
isForeground = isForeground,
|
||||||
|
locationPreciseEnabled = locationPreciseEnabled,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
fun hasCoarseLocationPermission(): Boolean {
|
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
|
||||||
return (
|
|
||||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
companion object {
|
||||||
|
internal fun forTesting(
|
||||||
|
appContext: Context,
|
||||||
|
dataSource: LocationDataSource,
|
||||||
|
json: Json = Json { ignoreUnknownKeys = true },
|
||||||
|
isForeground: () -> Boolean = { true },
|
||||||
|
locationPreciseEnabled: () -> Boolean = { true },
|
||||||
|
): LocationHandler =
|
||||||
|
LocationHandler(
|
||||||
|
appContext = appContext,
|
||||||
|
dataSource = dataSource,
|
||||||
|
json = json,
|
||||||
|
isForeground = isForeground,
|
||||||
|
locationPreciseEnabled = locationPreciseEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +97,7 @@ class LocationHandler(
|
|||||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
|
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) {
|
||||||
return GatewaySession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_PERMISSION_REQUIRED",
|
code = "LOCATION_PERMISSION_REQUIRED",
|
||||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||||
@ -49,9 +107,9 @@ class LocationHandler(
|
|||||||
val preciseEnabled = locationPreciseEnabled()
|
val preciseEnabled = locationPreciseEnabled()
|
||||||
val accuracy =
|
val accuracy =
|
||||||
when (desiredAccuracy) {
|
when (desiredAccuracy) {
|
||||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||||
"coarse" -> "coarse"
|
"coarse" -> "coarse"
|
||||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||||
}
|
}
|
||||||
val providers =
|
val providers =
|
||||||
when (accuracy) {
|
when (accuracy) {
|
||||||
@ -61,7 +119,7 @@ class LocationHandler(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val payload =
|
val payload =
|
||||||
location.getLocation(
|
dataSource.fetchLocation(
|
||||||
desiredProviders = providers,
|
desiredProviders = providers,
|
||||||
maxAgeMs = maxAgeMs,
|
maxAgeMs = maxAgeMs,
|
||||||
timeoutMs = timeoutMs,
|
timeoutMs = timeoutMs,
|
||||||
|
|||||||
@ -16,4 +16,16 @@ class SmsHandler(
|
|||||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||||
|
val res = sms.search(paramsJson)
|
||||||
|
if (res.ok) {
|
||||||
|
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||||
|
} else {
|
||||||
|
val error = res.error ?: "SMS_SEARCH_FAILED"
|
||||||
|
val idx = error.indexOf(':')
|
||||||
|
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
|
||||||
|
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,19 +3,27 @@ package ai.openclaw.app.node
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.Telephony
|
||||||
import android.telephony.SmsManager as AndroidSmsManager
|
import android.telephony.SmsManager as AndroidSmsManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.Serializable
|
||||||
import ai.openclaw.app.PermissionRequester
|
import ai.openclaw.app.PermissionRequester
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends SMS messages via the Android SMS API.
|
* Sends SMS messages via the Android SMS API.
|
||||||
* Requires SEND_SMS permission to be granted.
|
* Requires SEND_SMS permission to be granted.
|
||||||
|
*
|
||||||
|
* Also provides SMS query functionality with READ_SMS permission.
|
||||||
*/
|
*/
|
||||||
class SmsManager(private val context: Context) {
|
class SmsManager(private val context: Context) {
|
||||||
|
|
||||||
@ -30,6 +38,30 @@ class SmsManager(private val context: Context) {
|
|||||||
val payloadJson: String,
|
val payloadJson: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single SMS message
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SmsMessage(
|
||||||
|
val id: Long,
|
||||||
|
val threadId: Long,
|
||||||
|
val address: String?,
|
||||||
|
val person: String?,
|
||||||
|
val date: Long,
|
||||||
|
val dateSent: Long,
|
||||||
|
val read: Boolean,
|
||||||
|
val type: Int,
|
||||||
|
val body: String?,
|
||||||
|
val status: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val messages: List<SmsMessage>,
|
||||||
|
val error: String? = null,
|
||||||
|
val payloadJson: String,
|
||||||
|
)
|
||||||
|
|
||||||
internal data class ParsedParams(
|
internal data class ParsedParams(
|
||||||
val to: String,
|
val to: String,
|
||||||
val message: String,
|
val message: String,
|
||||||
@ -44,12 +76,30 @@ class SmsManager(private val context: Context) {
|
|||||||
) : ParseResult()
|
) : ParseResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class QueryParams(
|
||||||
|
val startTime: Long? = null,
|
||||||
|
val endTime: Long? = null,
|
||||||
|
val contactName: String? = null,
|
||||||
|
val phoneNumber: String? = null,
|
||||||
|
val keyword: String? = null,
|
||||||
|
val type: Int? = null,
|
||||||
|
val isRead: Boolean? = null,
|
||||||
|
val limit: Int = DEFAULT_SMS_LIMIT,
|
||||||
|
val offset: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed class QueryParseResult {
|
||||||
|
data class Ok(val params: QueryParams) : QueryParseResult()
|
||||||
|
data class Error(val error: String) : QueryParseResult()
|
||||||
|
}
|
||||||
|
|
||||||
internal data class SendPlan(
|
internal data class SendPlan(
|
||||||
val parts: List<String>,
|
val parts: List<String>,
|
||||||
val useMultipart: Boolean,
|
val useMultipart: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val DEFAULT_SMS_LIMIT = 25
|
||||||
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
||||||
@ -88,6 +138,52 @@ class SmsManager(private val context: Context) {
|
|||||||
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
|
||||||
|
val params = paramsJson?.trim().orEmpty()
|
||||||
|
if (params.isEmpty()) {
|
||||||
|
return QueryParseResult.Ok(QueryParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
val obj = try {
|
||||||
|
json.parseToJsonElement(params).jsonObject
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
|
||||||
|
}
|
||||||
|
|
||||||
|
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||||
|
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||||
|
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
|
||||||
|
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
|
||||||
|
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
|
||||||
|
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||||
|
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
|
||||||
|
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
|
||||||
|
.coerceIn(1, 200)
|
||||||
|
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||||
|
.coerceAtLeast(0)
|
||||||
|
|
||||||
|
// Validate time range
|
||||||
|
if (startTime != null && endTime != null && startTime > endTime) {
|
||||||
|
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueryParseResult.Ok(QueryParams(
|
||||||
|
startTime = startTime,
|
||||||
|
endTime = endTime,
|
||||||
|
contactName = contactName,
|
||||||
|
phoneNumber = phoneNumber,
|
||||||
|
keyword = keyword,
|
||||||
|
type = type,
|
||||||
|
isRead = isRead,
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePhoneNumber(phone: String): String {
|
||||||
|
return phone.replace(Regex("""[\s\-()]"""), "")
|
||||||
|
}
|
||||||
|
|
||||||
internal fun buildSendPlan(
|
internal fun buildSendPlan(
|
||||||
message: String,
|
message: String,
|
||||||
divider: (String) -> List<String>,
|
divider: (String) -> List<String>,
|
||||||
@ -112,6 +208,25 @@ class SmsManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun buildQueryPayloadJson(
|
||||||
|
json: Json = JsonConfig,
|
||||||
|
ok: Boolean,
|
||||||
|
messages: List<SmsMessage>,
|
||||||
|
error: String? = null,
|
||||||
|
): String {
|
||||||
|
val messagesArray = json.encodeToString(messages)
|
||||||
|
val messagesElement = json.parseToJsonElement(messagesArray)
|
||||||
|
val payload = mutableMapOf<String, JsonElement>(
|
||||||
|
"ok" to JsonPrimitive(ok),
|
||||||
|
"count" to JsonPrimitive(messages.size),
|
||||||
|
"messages" to messagesElement
|
||||||
|
)
|
||||||
|
if (!ok && error != null) {
|
||||||
|
payload["error"] = JsonPrimitive(error)
|
||||||
|
}
|
||||||
|
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasSmsPermission(): Boolean {
|
fun hasSmsPermission(): Boolean {
|
||||||
@ -121,10 +236,28 @@ class SmsManager(private val context: Context) {
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasReadSmsPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.READ_SMS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasReadContactsPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.READ_CONTACTS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
fun canSendSms(): Boolean {
|
fun canSendSms(): Boolean {
|
||||||
return hasSmsPermission() && hasTelephonyFeature()
|
return hasSmsPermission() && hasTelephonyFeature()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun canReadSms(): Boolean {
|
||||||
|
return hasReadSmsPermission() && hasTelephonyFeature()
|
||||||
|
}
|
||||||
|
|
||||||
fun hasTelephonyFeature(): Boolean {
|
fun hasTelephonyFeature(): Boolean {
|
||||||
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||||
}
|
}
|
||||||
@ -208,6 +341,20 @@ class SmsManager(private val context: Context) {
|
|||||||
return results[Manifest.permission.SEND_SMS] == true
|
return results[Manifest.permission.SEND_SMS] == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureReadSmsPermission(): Boolean {
|
||||||
|
if (hasReadSmsPermission()) return true
|
||||||
|
val requester = permissionRequester ?: return false
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
|
||||||
|
return results[Manifest.permission.READ_SMS] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureReadContactsPermission(): Boolean {
|
||||||
|
if (hasReadContactsPermission()) return true
|
||||||
|
val requester = permissionRequester ?: return false
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
|
||||||
|
return results[Manifest.permission.READ_CONTACTS] == true
|
||||||
|
}
|
||||||
|
|
||||||
private fun okResult(to: String, message: String): SendResult {
|
private fun okResult(to: String, message: String): SendResult {
|
||||||
return SendResult(
|
return SendResult(
|
||||||
ok = true,
|
ok = true,
|
||||||
@ -227,4 +374,240 @@ class SmsManager(private val context: Context) {
|
|||||||
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search SMS messages with the specified parameters.
|
||||||
|
*
|
||||||
|
* @param paramsJson JSON with optional fields:
|
||||||
|
* - startTime (Long): Start time in milliseconds
|
||||||
|
* - endTime (Long): End time in milliseconds
|
||||||
|
* - contactName (String): Contact name to search
|
||||||
|
* - phoneNumber (String): Phone number to search (supports partial matching)
|
||||||
|
* - keyword (String): Keyword to search in message body
|
||||||
|
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
|
||||||
|
* - isRead (Boolean): Read status
|
||||||
|
* - limit (Int): Number of records to return (default: 25, range: 1-200)
|
||||||
|
* - offset (Int): Number of records to skip (default: 0)
|
||||||
|
* @return SearchResult containing the list of SMS messages or an error
|
||||||
|
*/
|
||||||
|
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
|
||||||
|
if (!hasTelephonyFeature()) {
|
||||||
|
return@withContext SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = "SMS_UNAVAILABLE: telephony not available",
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureReadSmsPermission()) {
|
||||||
|
return@withContext SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parseResult = parseQueryParams(paramsJson, json)
|
||||||
|
if (parseResult is QueryParseResult.Error) {
|
||||||
|
return@withContext SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = parseResult.error,
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val params = (parseResult as QueryParseResult.Ok).params
|
||||||
|
|
||||||
|
return@withContext try {
|
||||||
|
// Get phone numbers from contact name if provided
|
||||||
|
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
|
||||||
|
if (!ensureReadContactsPermission()) {
|
||||||
|
return@withContext SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
getPhoneNumbersFromContactName(params.contactName)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val messages = querySmsMessages(params, phoneNumbers)
|
||||||
|
SearchResult(
|
||||||
|
ok = true,
|
||||||
|
messages = messages,
|
||||||
|
error = null,
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
SearchResult(
|
||||||
|
ok = false,
|
||||||
|
messages = emptyList(),
|
||||||
|
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
|
||||||
|
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all phone numbers associated with a contact name
|
||||||
|
*/
|
||||||
|
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
|
||||||
|
val phoneNumbers = mutableListOf<String>()
|
||||||
|
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
|
||||||
|
val selectionArgs = arrayOf("%$contactName%")
|
||||||
|
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
|
||||||
|
selection,
|
||||||
|
selectionArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val number = it.getString(numberIndex)
|
||||||
|
if (!number.isNullOrBlank()) {
|
||||||
|
phoneNumbers.add(normalizePhoneNumber(number))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return phoneNumbers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query SMS messages based on the provided parameters
|
||||||
|
*/
|
||||||
|
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
|
||||||
|
val messages = mutableListOf<SmsMessage>()
|
||||||
|
|
||||||
|
// Build selection and selectionArgs
|
||||||
|
val selections = mutableListOf<String>()
|
||||||
|
val selectionArgs = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Time range
|
||||||
|
if (params.startTime != null) {
|
||||||
|
selections.add("${Telephony.Sms.DATE} >= ?")
|
||||||
|
selectionArgs.add(params.startTime.toString())
|
||||||
|
}
|
||||||
|
if (params.endTime != null) {
|
||||||
|
selections.add("${Telephony.Sms.DATE} <= ?")
|
||||||
|
selectionArgs.add(params.endTime.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone numbers (from contact name or direct phone number)
|
||||||
|
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
|
||||||
|
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
|
||||||
|
} else {
|
||||||
|
phoneNumbers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPhoneNumbers.isNotEmpty()) {
|
||||||
|
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
|
||||||
|
"${Telephony.Sms.ADDRESS} LIKE ?"
|
||||||
|
}
|
||||||
|
selections.add("($addressSelection)")
|
||||||
|
allPhoneNumbers.forEach {
|
||||||
|
selectionArgs.add("%$it%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword in body
|
||||||
|
if (!params.keyword.isNullOrEmpty()) {
|
||||||
|
selections.add("${Telephony.Sms.BODY} LIKE ?")
|
||||||
|
selectionArgs.add("%${params.keyword}%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type
|
||||||
|
if (params.type != null) {
|
||||||
|
selections.add("${Telephony.Sms.TYPE} = ?")
|
||||||
|
selectionArgs.add(params.type.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read status
|
||||||
|
if (params.isRead != null) {
|
||||||
|
selections.add("${Telephony.Sms.READ} = ?")
|
||||||
|
selectionArgs.add(if (params.isRead) "1" else "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
val selection = if (selections.isNotEmpty()) {
|
||||||
|
selections.joinToString(" AND ")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
|
||||||
|
selectionArgs.toTypedArray()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
|
||||||
|
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
Telephony.Sms.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
Telephony.Sms._ID,
|
||||||
|
Telephony.Sms.THREAD_ID,
|
||||||
|
Telephony.Sms.ADDRESS,
|
||||||
|
Telephony.Sms.PERSON,
|
||||||
|
Telephony.Sms.DATE,
|
||||||
|
Telephony.Sms.DATE_SENT,
|
||||||
|
Telephony.Sms.READ,
|
||||||
|
Telephony.Sms.TYPE,
|
||||||
|
Telephony.Sms.BODY,
|
||||||
|
Telephony.Sms.STATUS
|
||||||
|
),
|
||||||
|
selection,
|
||||||
|
selectionArgsArray,
|
||||||
|
sortOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
|
||||||
|
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
|
||||||
|
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
|
||||||
|
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
|
||||||
|
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
|
||||||
|
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
|
||||||
|
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
|
||||||
|
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
|
||||||
|
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
|
||||||
|
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
while (it.moveToNext() && count < params.limit) {
|
||||||
|
val message = SmsMessage(
|
||||||
|
id = it.getLong(idIndex),
|
||||||
|
threadId = it.getLong(threadIdIndex),
|
||||||
|
address = it.getString(addressIndex),
|
||||||
|
person = it.getString(personIndex),
|
||||||
|
date = it.getLong(dateIndex),
|
||||||
|
dateSent = it.getLong(dateSentIndex),
|
||||||
|
read = it.getInt(readIndex) == 1,
|
||||||
|
type = it.getInt(typeIndex),
|
||||||
|
body = it.getString(bodyIndex),
|
||||||
|
status = it.getInt(statusIndex)
|
||||||
|
)
|
||||||
|
messages.add(message)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
|
|||||||
|
|
||||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||||
Send("sms.send"),
|
Send("sms.send"),
|
||||||
|
Search("sms.search"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import ai.openclaw.app.MainViewModel
|
|||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||||
@ -45,6 +45,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = {
|
factory = {
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
|
visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
@ -127,6 +128,16 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
webViewRef.value = this
|
webViewRef.value = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
update = { webView ->
|
||||||
|
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||||
|
if (visible) {
|
||||||
|
webView.resumeTimers()
|
||||||
|
webView.onResume()
|
||||||
|
} else {
|
||||||
|
webView.onPause()
|
||||||
|
webView.pauseTimers()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package ai.openclaw.app.ui
|
package ai.openclaw.app.ui
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ai.openclaw.app.MainViewModel
|
import ai.openclaw.app.MainViewModel
|
||||||
import ai.openclaw.app.ui.mobileCardSurface
|
import ai.openclaw.app.ui.mobileCardSurface
|
||||||
@ -60,6 +62,7 @@ private enum class ConnectInputMode {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
val isConnected by viewModel.isConnected.collectAsState()
|
val isConnected by viewModel.isConnected.collectAsState()
|
||||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
@ -134,7 +137,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
|
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||||
|
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
@ -279,6 +283,46 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDiagnostics) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = mobileWarningSoft,
|
||||||
|
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.25f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text("Last gateway error", style = mobileHeadline, color = mobileWarning)
|
||||||
|
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||||
|
Text("OpenClaw Android ${openClawAndroidVersionLabel()}", style = mobileCaption1, color = mobileTextSecondary)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
copyGatewayDiagnosticsReport(
|
||||||
|
context = context,
|
||||||
|
screen = "connect tab",
|
||||||
|
gatewayAddress = activeEndpoint,
|
||||||
|
statusText = statusLabel,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = mobileCardSurface,
|
||||||
|
contentColor = mobileWarning,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.3f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
package ai.openclaw.app.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import ai.openclaw.app.BuildConfig
|
||||||
|
|
||||||
|
internal fun openClawAndroidVersionLabel(): String {
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun gatewayStatusForDisplay(statusText: String): String {
|
||||||
|
return statusText.trim().ifEmpty { "Offline" }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
|
||||||
|
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||||
|
return lower != "offline" && !lower.contains("connecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||||
|
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||||
|
return lower.contains("pair") || lower.contains("approve")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildGatewayDiagnosticsReport(
|
||||||
|
screen: String,
|
||||||
|
gatewayAddress: String,
|
||||||
|
statusText: String,
|
||||||
|
): String {
|
||||||
|
val device =
|
||||||
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { "Android" }
|
||||||
|
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
|
||||||
|
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
|
||||||
|
val status = gatewayStatusForDisplay(statusText)
|
||||||
|
return """
|
||||||
|
Help diagnose this OpenClaw Android gateway connection failure.
|
||||||
|
|
||||||
|
Please:
|
||||||
|
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||||
|
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||||
|
- quote the exact app status/error below
|
||||||
|
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||||
|
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||||
|
- give the next exact command or tap
|
||||||
|
|
||||||
|
Debug info:
|
||||||
|
- screen: $screen
|
||||||
|
- app version: ${openClawAndroidVersionLabel()}
|
||||||
|
- device: $device
|
||||||
|
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
|
||||||
|
- gateway address: $endpoint
|
||||||
|
- status/error: $status
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun copyGatewayDiagnosticsReport(
|
||||||
|
context: Context,
|
||||||
|
screen: String,
|
||||||
|
gatewayAddress: String,
|
||||||
|
statusText: String,
|
||||||
|
) {
|
||||||
|
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
|
||||||
|
val report = buildGatewayDiagnosticsReport(screen = screen, gatewayAddress = gatewayAddress, statusText = statusText)
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostics", report))
|
||||||
|
Toast.makeText(context, "Copied gateway diagnostics", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import android.hardware.SensorManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@ -60,6 +61,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.filled.ChatBubble
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
@ -91,6 +93,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import ai.openclaw.app.BuildConfig
|
||||||
import ai.openclaw.app.LocationMode
|
import ai.openclaw.app.LocationMode
|
||||||
import ai.openclaw.app.MainViewModel
|
import ai.openclaw.app.MainViewModel
|
||||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||||
@ -236,8 +239,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
|
|
||||||
val smsAvailable =
|
val smsAvailable =
|
||||||
remember(context) {
|
remember(context) {
|
||||||
|
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||||
}
|
}
|
||||||
|
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||||
val motionAvailable =
|
val motionAvailable =
|
||||||
remember(context) {
|
remember(context) {
|
||||||
hasMotionCapabilities(context)
|
hasMotionCapabilities(context)
|
||||||
@ -287,11 +292,15 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
var enableSms by
|
var enableSms by
|
||||||
rememberSaveable {
|
rememberSaveable {
|
||||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
mutableStateOf(
|
||||||
|
smsAvailable &&
|
||||||
|
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||||
|
isPermissionGranted(context, Manifest.permission.READ_SMS)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
var enableCallLog by
|
var enableCallLog by
|
||||||
rememberSaveable {
|
rememberSaveable {
|
||||||
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||||
@ -309,7 +318,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||||
PermissionToggle.CallLog -> enableCallLog = enabled
|
PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,8 +345,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
!motionPermissionRequired ||
|
!motionPermissionRequired ||
|
||||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||||
PermissionToggle.Sms ->
|
PermissionToggle.Sms ->
|
||||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
!smsAvailable ||
|
||||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||||
|
isPermissionGranted(context, Manifest.permission.READ_SMS))
|
||||||
|
PermissionToggle.CallLog ->
|
||||||
|
!callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||||
@ -361,6 +373,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
enableSms,
|
enableSms,
|
||||||
enableCallLog,
|
enableCallLog,
|
||||||
smsAvailable,
|
smsAvailable,
|
||||||
|
callLogAvailable,
|
||||||
motionAvailable,
|
motionAvailable,
|
||||||
) {
|
) {
|
||||||
val enabled = mutableListOf<String>()
|
val enabled = mutableListOf<String>()
|
||||||
@ -375,7 +388,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
if (enableCalendar) enabled += "Calendar"
|
if (enableCalendar) enabled += "Calendar"
|
||||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||||
if (smsAvailable && enableSms) enabled += "SMS"
|
if (smsAvailable && enableSms) enabled += "SMS"
|
||||||
if (enableCallLog) enabled += "Call Log"
|
if (callLogAvailable && enableCallLog) enabled += "Call Log"
|
||||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,6 +617,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
motionPermissionRequired = motionPermissionRequired,
|
motionPermissionRequired = motionPermissionRequired,
|
||||||
enableSms = enableSms,
|
enableSms = enableSms,
|
||||||
smsAvailable = smsAvailable,
|
smsAvailable = smsAvailable,
|
||||||
|
callLogAvailable = callLogAvailable,
|
||||||
enableCallLog = enableCallLog,
|
enableCallLog = enableCallLog,
|
||||||
context = context,
|
context = context,
|
||||||
onDiscoveryChange = { checked ->
|
onDiscoveryChange = { checked ->
|
||||||
@ -698,16 +712,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
requestPermissionToggle(
|
requestPermissionToggle(
|
||||||
PermissionToggle.Sms,
|
PermissionToggle.Sms,
|
||||||
checked,
|
checked,
|
||||||
listOf(Manifest.permission.SEND_SMS),
|
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCallLogChange = { checked ->
|
onCallLogChange = { checked ->
|
||||||
|
if (!callLogAvailable) {
|
||||||
|
setPermissionToggleEnabled(PermissionToggle.CallLog, false)
|
||||||
|
} else {
|
||||||
requestPermissionToggle(
|
requestPermissionToggle(
|
||||||
PermissionToggle.CallLog,
|
PermissionToggle.CallLog,
|
||||||
checked,
|
checked,
|
||||||
listOf(Manifest.permission.READ_CALL_LOG),
|
listOf(Manifest.permission.READ_CALL_LOG),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
OnboardingStep.FinalCheck ->
|
OnboardingStep.FinalCheck ->
|
||||||
@ -1299,6 +1317,7 @@ private fun PermissionsStep(
|
|||||||
motionPermissionRequired: Boolean,
|
motionPermissionRequired: Boolean,
|
||||||
enableSms: Boolean,
|
enableSms: Boolean,
|
||||||
smsAvailable: Boolean,
|
smsAvailable: Boolean,
|
||||||
|
callLogAvailable: Boolean,
|
||||||
enableCallLog: Boolean,
|
enableCallLog: Boolean,
|
||||||
context: Context,
|
context: Context,
|
||||||
onDiscoveryChange: (Boolean) -> Unit,
|
onDiscoveryChange: (Boolean) -> Unit,
|
||||||
@ -1437,12 +1456,15 @@ private fun PermissionsStep(
|
|||||||
InlineDivider()
|
InlineDivider()
|
||||||
PermissionToggleRow(
|
PermissionToggleRow(
|
||||||
title = "SMS",
|
title = "SMS",
|
||||||
subtitle = "Send text messages via the gateway",
|
subtitle = "Send and search text messages via the gateway",
|
||||||
checked = enableSms,
|
checked = enableSms,
|
||||||
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
|
granted =
|
||||||
|
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||||
|
isPermissionGranted(context, Manifest.permission.READ_SMS),
|
||||||
onCheckedChange = onSmsChange,
|
onCheckedChange = onSmsChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (callLogAvailable) {
|
||||||
InlineDivider()
|
InlineDivider()
|
||||||
PermissionToggleRow(
|
PermissionToggleRow(
|
||||||
title = "Call Log",
|
title = "Call Log",
|
||||||
@ -1451,6 +1473,7 @@ private fun PermissionsStep(
|
|||||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||||
onCheckedChange = onCallLogChange,
|
onCheckedChange = onCallLogChange,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1511,6 +1534,12 @@ private fun FinalStep(
|
|||||||
enabledPermissions: String,
|
enabledPermissions: String,
|
||||||
methodLabel: String,
|
methodLabel: String,
|
||||||
) {
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val gatewayAddress = parsedGateway?.displayUrl ?: "Invalid gateway URL"
|
||||||
|
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||||
|
val showDiagnostics = gatewayStatusHasDiagnostics(statusText)
|
||||||
|
val pairingRequired = gatewayStatusLooksLikePairing(statusText)
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
||||||
|
|
||||||
@ -1523,7 +1552,7 @@ private fun FinalStep(
|
|||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon = Icons.Default.Cloud,
|
icon = Icons.Default.Cloud,
|
||||||
label = "Gateway",
|
label = "Gateway",
|
||||||
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
|
value = gatewayAddress,
|
||||||
accentColor = Color(0xFF7C5AC7),
|
accentColor = Color(0xFF7C5AC7),
|
||||||
)
|
)
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
@ -1607,7 +1636,7 @@ private fun FinalStep(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
color = onboardingWarningSoft,
|
color = onboardingWarningSoft,
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(14.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
@ -1632,10 +1661,62 @@ private fun FinalStep(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
|
Text(
|
||||||
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
if (pairingRequired) "Pairing Required" else "Connection Failed",
|
||||||
|
style = onboardingHeadlineStyle,
|
||||||
|
color = onboardingWarning,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (pairingRequired) {
|
||||||
|
"Approve this phone on the gateway host, or copy the report below."
|
||||||
|
} else {
|
||||||
|
"Copy this report and give it to your Claw."
|
||||||
|
},
|
||||||
|
style = onboardingCalloutStyle,
|
||||||
|
color = onboardingTextSecondary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (showDiagnostics) {
|
||||||
|
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = onboardingCommandBg,
|
||||||
|
border = BorderStroke(1.dp, onboardingCommandBorder),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
statusLabel,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = onboardingCommandText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"OpenClaw Android ${openClawAndroidVersionLabel()}",
|
||||||
|
style = onboardingCaption1Style,
|
||||||
|
color = onboardingTextSecondary,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
copyGatewayDiagnosticsReport(
|
||||||
|
context = context,
|
||||||
|
screen = "onboarding final check",
|
||||||
|
gatewayAddress = gatewayAddress,
|
||||||
|
statusText = statusLabel,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = onboardingSurface, contentColor = onboardingWarning),
|
||||||
|
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.3f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Copy Report for Claw", style = onboardingCalloutStyle.copy(fontWeight = FontWeight.Bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pairingRequired) {
|
||||||
CommandBlock("openclaw devices list")
|
CommandBlock("openclaw devices list")
|
||||||
CommandBlock("openclaw devices approve <requestId>")
|
CommandBlock("openclaw devices approve <requestId>")
|
||||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||||
@ -1644,6 +1725,7 @@ private fun FinalStep(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SummaryCard(
|
private fun SummaryCard(
|
||||||
|
|||||||
@ -39,7 +39,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@ -68,10 +70,19 @@ private enum class StatusVisual {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||||
|
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Stop TTS when user navigates away from voice tab
|
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
|
||||||
|
// alive after the first visit so repeated tab switches do not rebuild their UI trees.
|
||||||
LaunchedEffect(activeTab) {
|
LaunchedEffect(activeTab) {
|
||||||
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
|
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
|
||||||
|
if (activeTab == HomeTab.Chat) {
|
||||||
|
chatTabStarted = true
|
||||||
|
}
|
||||||
|
if (activeTab == HomeTab.Screen) {
|
||||||
|
screenTabStarted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
@ -120,11 +131,35 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
.consumeWindowInsets(innerPadding)
|
.consumeWindowInsets(innerPadding)
|
||||||
.background(mobileBackgroundGradient),
|
.background(mobileBackgroundGradient),
|
||||||
) {
|
) {
|
||||||
|
if (chatTabStarted) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.alpha(if (activeTab == HomeTab.Chat) 1f else 0f)
|
||||||
|
.zIndex(if (activeTab == HomeTab.Chat) 1f else 0f),
|
||||||
|
) {
|
||||||
|
ChatSheet(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenTabStarted) {
|
||||||
|
ScreenTabScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
visible = activeTab == HomeTab.Screen,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.alpha(if (activeTab == HomeTab.Screen) 1f else 0f)
|
||||||
|
.zIndex(if (activeTab == HomeTab.Screen) 1f else 0f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (activeTab) {
|
when (activeTab) {
|
||||||
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
|
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
|
||||||
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
|
HomeTab.Chat -> if (!chatTabStarted) ChatSheet(viewModel = viewModel)
|
||||||
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
|
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
|
||||||
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
|
HomeTab.Screen -> Unit
|
||||||
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
|
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -132,16 +167,19 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
private fun ScreenTabScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||||
val isConnected by viewModel.isConnected.collectAsState()
|
val isConnected by viewModel.isConnected.collectAsState()
|
||||||
LaunchedEffect(isConnected) {
|
var refreshedForCurrentConnection by rememberSaveable(isConnected) { mutableStateOf(false) }
|
||||||
if (isConnected) {
|
|
||||||
|
LaunchedEffect(isConnected, visible, refreshedForCurrentConnection) {
|
||||||
|
if (visible && isConnected && !refreshedForCurrentConnection) {
|
||||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||||
|
refreshedForCurrentConnection = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasScreen(viewModel = viewModel, visible = visible, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -149,8 +149,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
val smsPermissionAvailable =
|
val smsPermissionAvailable =
|
||||||
remember {
|
remember {
|
||||||
|
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||||
}
|
}
|
||||||
|
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||||
val photosPermission =
|
val photosPermission =
|
||||||
if (Build.VERSION.SDK_INT >= 33) {
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
Manifest.permission.READ_MEDIA_IMAGES
|
||||||
@ -247,12 +249,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
remember {
|
remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||||
PackageManager.PERMISSION_GRANTED,
|
PackageManager.PERMISSION_GRANTED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val smsPermissionLauncher =
|
val smsPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
smsPermissionGranted = granted
|
val sendOk = perms[Manifest.permission.SEND_SMS] == true
|
||||||
|
val readOk = perms[Manifest.permission.READ_SMS] == true
|
||||||
|
smsPermissionGranted = sendOk && readOk
|
||||||
viewModel.refreshGatewayConnection()
|
viewModel.refreshGatewayConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,6 +293,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
smsPermissionGranted =
|
smsPermissionGranted =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -507,7 +515,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
colors = listItemColors,
|
colors = listItemColors,
|
||||||
headlineContent = { Text("SMS", style = mobileHeadline) },
|
headlineContent = { Text("SMS", style = mobileHeadline) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text("Send SMS from this device.", style = mobileCallout)
|
Text("Send and search SMS from this device.", style = mobileCallout)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Button(
|
Button(
|
||||||
@ -515,7 +523,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
if (smsPermissionGranted) {
|
if (smsPermissionGranted) {
|
||||||
openAppSettings(context)
|
openAppSettings(context)
|
||||||
} else {
|
} else {
|
||||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = settingsPrimaryButtonColors(),
|
colors = settingsPrimaryButtonColors(),
|
||||||
@ -616,6 +624,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (callLogPermissionAvailable) {
|
||||||
HorizontalDivider(color = mobileBorder)
|
HorizontalDivider(color = mobileBorder)
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@ -641,6 +650,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (motionAvailable) {
|
if (motionAvailable) {
|
||||||
HorizontalDivider(color = mobileBorder)
|
HorizontalDivider(color = mobileBorder)
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|||||||
@ -63,7 +63,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
LaunchedEffect(mainSessionKey) {
|
||||||
viewModel.loadChat(mainSessionKey)
|
viewModel.loadChat(mainSessionKey)
|
||||||
viewModel.refreshChatSessions(limit = 200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@ -1,338 +0,0 @@
|
|||||||
package ai.openclaw.app.voice
|
|
||||||
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFormat
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.AudioTrack
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import okhttp3.*
|
|
||||||
import org.json.JSONObject
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams text chunks to ElevenLabs WebSocket API and plays audio in real-time.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Create instance with voice/API config
|
|
||||||
* 2. Call [start] to open WebSocket + AudioTrack
|
|
||||||
* 3. Call [sendText] with incremental text chunks as they arrive
|
|
||||||
* 4. Call [finish] when the full response is ready (sends EOS to ElevenLabs)
|
|
||||||
* 5. Call [stop] to cancel/cleanup at any time
|
|
||||||
*
|
|
||||||
* Audio playback begins as soon as the first audio chunk arrives from ElevenLabs,
|
|
||||||
* typically within ~100ms of the first text chunk for eleven_flash_v2_5.
|
|
||||||
*
|
|
||||||
* Note: eleven_v3 does NOT support WebSocket streaming. Use eleven_flash_v2_5
|
|
||||||
* or eleven_flash_v2 for lowest latency.
|
|
||||||
*/
|
|
||||||
class ElevenLabsStreamingTts(
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val voiceId: String,
|
|
||||||
private val apiKey: String,
|
|
||||||
private val modelId: String = "eleven_flash_v2_5",
|
|
||||||
private val outputFormat: String = "pcm_24000",
|
|
||||||
private val sampleRate: Int = 24000,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "ElevenLabsStreamTTS"
|
|
||||||
private const val BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech"
|
|
||||||
|
|
||||||
/** Models that support WebSocket input streaming */
|
|
||||||
val STREAMING_MODELS = setOf(
|
|
||||||
"eleven_flash_v2_5",
|
|
||||||
"eleven_flash_v2",
|
|
||||||
"eleven_multilingual_v2",
|
|
||||||
"eleven_turbo_v2_5",
|
|
||||||
"eleven_turbo_v2",
|
|
||||||
"eleven_monolingual_v1",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun supportsStreaming(modelId: String): Boolean = modelId in STREAMING_MODELS
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _isPlaying = MutableStateFlow(false)
|
|
||||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
|
||||||
|
|
||||||
private var webSocket: WebSocket? = null
|
|
||||||
private var audioTrack: AudioTrack? = null
|
|
||||||
private var trackStarted = false
|
|
||||||
private var client: OkHttpClient? = null
|
|
||||||
@Volatile private var stopped = false
|
|
||||||
@Volatile private var finished = false
|
|
||||||
@Volatile var hasReceivedAudio = false
|
|
||||||
private set
|
|
||||||
private var drainJob: Job? = null
|
|
||||||
|
|
||||||
// Track text already sent so we only send incremental chunks
|
|
||||||
private var sentTextLength = 0
|
|
||||||
@Volatile private var wsReady = false
|
|
||||||
private val pendingText = mutableListOf<String>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the WebSocket connection and prepare AudioTrack.
|
|
||||||
* Must be called before [sendText].
|
|
||||||
*/
|
|
||||||
fun start() {
|
|
||||||
stopped = false
|
|
||||||
finished = false
|
|
||||||
hasReceivedAudio = false
|
|
||||||
sentTextLength = 0
|
|
||||||
trackStarted = false
|
|
||||||
wsReady = false
|
|
||||||
sentFullText = ""
|
|
||||||
synchronized(pendingText) { pendingText.clear() }
|
|
||||||
|
|
||||||
// Prepare AudioTrack
|
|
||||||
val minBuffer = AudioTrack.getMinBufferSize(
|
|
||||||
sampleRate,
|
|
||||||
AudioFormat.CHANNEL_OUT_MONO,
|
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
|
||||||
)
|
|
||||||
val bufferSize = max(minBuffer * 2, 8 * 1024)
|
|
||||||
val track = AudioTrack(
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.build(),
|
|
||||||
AudioFormat.Builder()
|
|
||||||
.setSampleRate(sampleRate)
|
|
||||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
|
||||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
|
||||||
.build(),
|
|
||||||
bufferSize,
|
|
||||||
AudioTrack.MODE_STREAM,
|
|
||||||
AudioManager.AUDIO_SESSION_ID_GENERATE,
|
|
||||||
)
|
|
||||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
|
||||||
track.release()
|
|
||||||
Log.e(TAG, "AudioTrack init failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioTrack = track
|
|
||||||
_isPlaying.value = true
|
|
||||||
|
|
||||||
// Open WebSocket
|
|
||||||
val url = "$BASE_URL/$voiceId/stream-input?model_id=$modelId&output_format=$outputFormat"
|
|
||||||
val okClient = OkHttpClient.Builder()
|
|
||||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
client = okClient
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.header("xi-api-key", apiKey)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
webSocket = okClient.newWebSocket(request, object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
Log.d(TAG, "WebSocket connected")
|
|
||||||
// Send initial config with voice settings
|
|
||||||
val config = JSONObject().apply {
|
|
||||||
put("text", " ")
|
|
||||||
put("voice_settings", JSONObject().apply {
|
|
||||||
put("stability", 0.5)
|
|
||||||
put("similarity_boost", 0.8)
|
|
||||||
put("use_speaker_boost", false)
|
|
||||||
})
|
|
||||||
put("generation_config", JSONObject().apply {
|
|
||||||
put("chunk_length_schedule", org.json.JSONArray(listOf(120, 160, 250, 290)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
webSocket.send(config.toString())
|
|
||||||
wsReady = true
|
|
||||||
// Flush any text that was queued before WebSocket was ready
|
|
||||||
synchronized(pendingText) {
|
|
||||||
for (queued in pendingText) {
|
|
||||||
val msg = JSONObject().apply { put("text", queued) }
|
|
||||||
webSocket.send(msg.toString())
|
|
||||||
Log.d(TAG, "flushed queued chunk: ${queued.length} chars")
|
|
||||||
}
|
|
||||||
pendingText.clear()
|
|
||||||
}
|
|
||||||
// Send deferred EOS if finish() was called before WebSocket was ready
|
|
||||||
if (finished) {
|
|
||||||
val eos = JSONObject().apply { put("text", "") }
|
|
||||||
webSocket.send(eos.toString())
|
|
||||||
Log.d(TAG, "sent deferred EOS")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
if (stopped) return
|
|
||||||
try {
|
|
||||||
val json = JSONObject(text)
|
|
||||||
val audio = json.optString("audio", "")
|
|
||||||
if (audio.isNotEmpty()) {
|
|
||||||
val pcmBytes = Base64.decode(audio, Base64.DEFAULT)
|
|
||||||
writeToTrack(pcmBytes)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error parsing WebSocket message: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
Log.e(TAG, "WebSocket error: ${t.message}")
|
|
||||||
stopped = true
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
Log.d(TAG, "WebSocket closed: $code $reason")
|
|
||||||
// Wait for AudioTrack to finish playing buffered audio, then cleanup
|
|
||||||
drainJob = scope.launch(Dispatchers.IO) {
|
|
||||||
drainAudioTrack()
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send incremental text. Call with the full accumulated text so far —
|
|
||||||
* only the new portion (since last send) will be transmitted.
|
|
||||||
*/
|
|
||||||
// Track the full text we've sent so we can detect replacement vs append
|
|
||||||
private var sentFullText = ""
|
|
||||||
|
|
||||||
/**
|
|
||||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
|
||||||
// event from a different thread — not a real divergence. Ignore it.
|
|
||||||
if (sentFullText.startsWith(fullText)) return true
|
|
||||||
* Returns true if text was accepted, false if text diverged (caller should restart).
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun sendText(fullText: String): Boolean {
|
|
||||||
if (stopped) return false
|
|
||||||
if (finished) return true // Already finishing — not a diverge, don't restart
|
|
||||||
|
|
||||||
// Detect text replacement: if the new text doesn't start with what we already sent,
|
|
||||||
// the stream has diverged (e.g., tool call interrupted and text was replaced).
|
|
||||||
if (sentFullText.isNotEmpty() && !fullText.startsWith(sentFullText)) {
|
|
||||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
|
||||||
// event from a different thread — not a real divergence. Ignore it.
|
|
||||||
if (sentFullText.startsWith(fullText)) return true
|
|
||||||
Log.d(TAG, "text diverged — sent='${sentFullText.take(60)}' new='${fullText.take(60)}'")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullText.length > sentTextLength) {
|
|
||||||
val newText = fullText.substring(sentTextLength)
|
|
||||||
sentTextLength = fullText.length
|
|
||||||
sentFullText = fullText
|
|
||||||
|
|
||||||
val ws = webSocket
|
|
||||||
if (ws != null && wsReady) {
|
|
||||||
val msg = JSONObject().apply { put("text", newText) }
|
|
||||||
ws.send(msg.toString())
|
|
||||||
Log.d(TAG, "sent chunk: ${newText.length} chars")
|
|
||||||
} else {
|
|
||||||
// Queue if WebSocket not connected yet (ws null = still connecting, wsReady false = handshake pending)
|
|
||||||
synchronized(pendingText) { pendingText.add(newText) }
|
|
||||||
Log.d(TAG, "queued chunk: ${newText.length} chars (ws not ready)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signal that no more text is coming. Sends EOS to ElevenLabs.
|
|
||||||
* The WebSocket will close after generating remaining audio.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun finish() {
|
|
||||||
if (stopped || finished) return
|
|
||||||
finished = true
|
|
||||||
val ws = webSocket
|
|
||||||
if (ws != null && wsReady) {
|
|
||||||
// Send empty text to signal end of stream
|
|
||||||
val eos = JSONObject().apply { put("text", "") }
|
|
||||||
ws.send(eos.toString())
|
|
||||||
Log.d(TAG, "sent EOS")
|
|
||||||
}
|
|
||||||
// else: WebSocket not ready yet; onOpen will send EOS after flushing queued text
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immediately stop playback and close everything.
|
|
||||||
*/
|
|
||||||
fun stop() {
|
|
||||||
stopped = true
|
|
||||||
finished = true
|
|
||||||
drainJob?.cancel()
|
|
||||||
drainJob = null
|
|
||||||
webSocket?.cancel()
|
|
||||||
webSocket = null
|
|
||||||
val track = audioTrack
|
|
||||||
audioTrack = null
|
|
||||||
if (track != null) {
|
|
||||||
try {
|
|
||||||
track.pause()
|
|
||||||
track.flush()
|
|
||||||
track.release()
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
|
||||||
_isPlaying.value = false
|
|
||||||
client?.dispatcher?.executorService?.shutdown()
|
|
||||||
client = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeToTrack(pcmBytes: ByteArray) {
|
|
||||||
val track = audioTrack ?: return
|
|
||||||
if (stopped) return
|
|
||||||
|
|
||||||
// Start playback on first audio chunk — avoids underrun
|
|
||||||
if (!trackStarted) {
|
|
||||||
track.play()
|
|
||||||
trackStarted = true
|
|
||||||
hasReceivedAudio = true
|
|
||||||
Log.d(TAG, "AudioTrack started on first chunk")
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
while (offset < pcmBytes.size && !stopped) {
|
|
||||||
val wrote = track.write(pcmBytes, offset, pcmBytes.size - offset)
|
|
||||||
if (wrote <= 0) {
|
|
||||||
if (stopped) return
|
|
||||||
Log.w(TAG, "AudioTrack write returned $wrote")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += wrote
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun drainAudioTrack() {
|
|
||||||
if (stopped) return
|
|
||||||
// Wait up to 10s for audio to finish playing
|
|
||||||
val deadline = System.currentTimeMillis() + 10_000
|
|
||||||
while (!stopped && System.currentTimeMillis() < deadline) {
|
|
||||||
// Check if track is still playing
|
|
||||||
val track = audioTrack ?: return
|
|
||||||
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) return
|
|
||||||
try {
|
|
||||||
Thread.sleep(100)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanup() {
|
|
||||||
val track = audioTrack
|
|
||||||
audioTrack = null
|
|
||||||
if (track != null) {
|
|
||||||
try {
|
|
||||||
track.stop()
|
|
||||||
track.release()
|
|
||||||
} catch (_: Throwable) {}
|
|
||||||
}
|
|
||||||
_isPlaying.value = false
|
|
||||||
client?.dispatcher?.executorService?.shutdown()
|
|
||||||
client = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package ai.openclaw.app.voice
|
|
||||||
|
|
||||||
import android.media.MediaDataSource
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
internal class StreamingMediaDataSource : MediaDataSource() {
|
|
||||||
private data class Chunk(val start: Long, val data: ByteArray)
|
|
||||||
|
|
||||||
private val lock = Object()
|
|
||||||
private val chunks = ArrayList<Chunk>()
|
|
||||||
private var totalSize: Long = 0
|
|
||||||
private var closed = false
|
|
||||||
private var finished = false
|
|
||||||
private var lastReadIndex = 0
|
|
||||||
|
|
||||||
fun append(data: ByteArray) {
|
|
||||||
if (data.isEmpty()) return
|
|
||||||
synchronized(lock) {
|
|
||||||
if (closed || finished) return
|
|
||||||
val chunk = Chunk(totalSize, data)
|
|
||||||
chunks.add(chunk)
|
|
||||||
totalSize += data.size.toLong()
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finish() {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (closed) return
|
|
||||||
finished = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fail() {
|
|
||||||
synchronized(lock) {
|
|
||||||
closed = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
|
||||||
if (position < 0) return -1
|
|
||||||
synchronized(lock) {
|
|
||||||
while (!closed && !finished && position >= totalSize) {
|
|
||||||
lock.wait()
|
|
||||||
}
|
|
||||||
if (closed) return -1
|
|
||||||
if (position >= totalSize && finished) return -1
|
|
||||||
|
|
||||||
val available = (totalSize - position).toInt()
|
|
||||||
val toRead = min(size, available)
|
|
||||||
var remaining = toRead
|
|
||||||
var destOffset = offset
|
|
||||||
var pos = position
|
|
||||||
|
|
||||||
var index = findChunkIndex(pos)
|
|
||||||
while (remaining > 0 && index < chunks.size) {
|
|
||||||
val chunk = chunks[index]
|
|
||||||
val inChunkOffset = (pos - chunk.start).toInt()
|
|
||||||
if (inChunkOffset >= chunk.data.size) {
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
|
|
||||||
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
|
|
||||||
remaining -= copyLen
|
|
||||||
destOffset += copyLen
|
|
||||||
pos += copyLen
|
|
||||||
if (inChunkOffset + copyLen >= chunk.data.size) {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toRead - remaining
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSize(): Long = -1
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
synchronized(lock) {
|
|
||||||
closed = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findChunkIndex(position: Long): Int {
|
|
||||||
var index = lastReadIndex
|
|
||||||
while (index < chunks.size) {
|
|
||||||
val chunk = chunks[index]
|
|
||||||
if (position < chunk.start + chunk.data.size) break
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
lastReadIndex = index
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,116 +4,23 @@ import ai.openclaw.app.normalizeMainKey
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
|
||||||
internal data class TalkProviderConfigSelection(
|
|
||||||
val provider: String,
|
|
||||||
val config: JsonObject,
|
|
||||||
val normalizedPayload: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class TalkModeGatewayConfigState(
|
internal data class TalkModeGatewayConfigState(
|
||||||
val activeProvider: String,
|
|
||||||
val normalizedPayload: Boolean,
|
|
||||||
val missingResolvedPayload: Boolean,
|
|
||||||
val mainSessionKey: String,
|
val mainSessionKey: String,
|
||||||
val defaultVoiceId: String?,
|
|
||||||
val voiceAliases: Map<String, String>,
|
|
||||||
val defaultModelId: String,
|
|
||||||
val defaultOutputFormat: String,
|
|
||||||
val apiKey: String?,
|
|
||||||
val interruptOnSpeech: Boolean?,
|
val interruptOnSpeech: Boolean?,
|
||||||
val silenceTimeoutMs: Long,
|
val silenceTimeoutMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal object TalkModeGatewayConfigParser {
|
internal object TalkModeGatewayConfigParser {
|
||||||
private const val defaultTalkProvider = "elevenlabs"
|
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
|
||||||
|
|
||||||
fun parse(
|
|
||||||
config: JsonObject?,
|
|
||||||
defaultProvider: String,
|
|
||||||
defaultModelIdFallback: String,
|
|
||||||
defaultOutputFormatFallback: String,
|
|
||||||
envVoice: String?,
|
|
||||||
sagVoice: String?,
|
|
||||||
envKey: String?,
|
|
||||||
): TalkModeGatewayConfigState {
|
|
||||||
val talk = config?.get("talk").asObjectOrNull()
|
val talk = config?.get("talk").asObjectOrNull()
|
||||||
val selection = selectTalkProviderConfig(talk)
|
|
||||||
val activeProvider = selection?.provider ?: defaultProvider
|
|
||||||
val activeConfig = selection?.config
|
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
|
||||||
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
val aliases =
|
|
||||||
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
|
||||||
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
|
||||||
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
|
|
||||||
}?.toMap().orEmpty()
|
|
||||||
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
val outputFormat =
|
|
||||||
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
|
||||||
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
|
|
||||||
|
|
||||||
return TalkModeGatewayConfigState(
|
return TalkModeGatewayConfigState(
|
||||||
activeProvider = activeProvider,
|
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
|
||||||
normalizedPayload = selection?.normalizedPayload == true,
|
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
|
||||||
missingResolvedPayload = talk != null && selection == null,
|
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
|
||||||
mainSessionKey = mainKey,
|
|
||||||
defaultVoiceId =
|
|
||||||
if (activeProvider == defaultProvider) {
|
|
||||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
|
||||||
} else {
|
|
||||||
voice
|
|
||||||
},
|
|
||||||
voiceAliases = aliases,
|
|
||||||
defaultModelId = model ?: defaultModelIdFallback,
|
|
||||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
|
|
||||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
|
|
||||||
interruptOnSpeech = interrupt,
|
|
||||||
silenceTimeoutMs = silenceTimeoutMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fallback(
|
|
||||||
defaultProvider: String,
|
|
||||||
defaultModelIdFallback: String,
|
|
||||||
defaultOutputFormatFallback: String,
|
|
||||||
envVoice: String?,
|
|
||||||
sagVoice: String?,
|
|
||||||
envKey: String?,
|
|
||||||
): TalkModeGatewayConfigState =
|
|
||||||
TalkModeGatewayConfigState(
|
|
||||||
activeProvider = defaultProvider,
|
|
||||||
normalizedPayload = false,
|
|
||||||
missingResolvedPayload = false,
|
|
||||||
mainSessionKey = "main",
|
|
||||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
|
|
||||||
voiceAliases = emptyMap(),
|
|
||||||
defaultModelId = defaultModelIdFallback,
|
|
||||||
defaultOutputFormat = defaultOutputFormatFallback,
|
|
||||||
apiKey = envKey?.takeIf { it.isNotEmpty() },
|
|
||||||
interruptOnSpeech = null,
|
|
||||||
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
|
||||||
if (talk == null) return null
|
|
||||||
selectResolvedTalkProviderConfig(talk)?.let { return it }
|
|
||||||
val rawProvider = talk["provider"].asStringOrNull()
|
|
||||||
val rawProviders = talk["providers"].asObjectOrNull()
|
|
||||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
|
||||||
if (hasNormalizedPayload) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return TalkProviderConfigSelection(
|
|
||||||
provider = defaultTalkProvider,
|
|
||||||
config = talk,
|
|
||||||
normalizedPayload = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,26 +34,8 @@ internal object TalkModeGatewayConfigParser {
|
|||||||
}
|
}
|
||||||
return timeout.toLong()
|
return timeout.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
|
|
||||||
val resolved = talk["resolved"].asObjectOrNull() ?: return null
|
|
||||||
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
|
|
||||||
return TalkProviderConfigSelection(
|
|
||||||
provider = providerId,
|
|
||||||
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
|
|
||||||
normalizedPayload = true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
|
||||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
|
||||||
return trimmed.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeTalkAliasKey(value: String): String =
|
|
||||||
value.trim().lowercase()
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? =
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
this?.let { element ->
|
this?.let { element ->
|
||||||
element as? JsonPrimitive
|
element as? JsonPrimitive
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,122 +0,0 @@
|
|||||||
package ai.openclaw.app.voice
|
|
||||||
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
|
|
||||||
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
|
||||||
|
|
||||||
internal data class TalkModeResolvedVoice(
|
|
||||||
val voiceId: String?,
|
|
||||||
val fallbackVoiceId: String?,
|
|
||||||
val defaultVoiceId: String?,
|
|
||||||
val currentVoiceId: String?,
|
|
||||||
val selectedVoiceName: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object TalkModeVoiceResolver {
|
|
||||||
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
|
|
||||||
val trimmed = value?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
val normalized = normalizeAliasKey(trimmed)
|
|
||||||
voiceAliases[normalized]?.let { return it }
|
|
||||||
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
|
|
||||||
return if (isLikelyVoiceId(trimmed)) trimmed else null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun resolveVoiceId(
|
|
||||||
preferred: String?,
|
|
||||||
fallbackVoiceId: String?,
|
|
||||||
defaultVoiceId: String?,
|
|
||||||
currentVoiceId: String?,
|
|
||||||
voiceOverrideActive: Boolean,
|
|
||||||
listVoices: suspend () -> List<ElevenLabsVoice>,
|
|
||||||
): TalkModeResolvedVoice {
|
|
||||||
val trimmed = preferred?.trim().orEmpty()
|
|
||||||
if (trimmed.isNotEmpty()) {
|
|
||||||
return TalkModeResolvedVoice(
|
|
||||||
voiceId = trimmed,
|
|
||||||
fallbackVoiceId = fallbackVoiceId,
|
|
||||||
defaultVoiceId = defaultVoiceId,
|
|
||||||
currentVoiceId = currentVoiceId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!fallbackVoiceId.isNullOrBlank()) {
|
|
||||||
return TalkModeResolvedVoice(
|
|
||||||
voiceId = fallbackVoiceId,
|
|
||||||
fallbackVoiceId = fallbackVoiceId,
|
|
||||||
defaultVoiceId = defaultVoiceId,
|
|
||||||
currentVoiceId = currentVoiceId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val first = listVoices().firstOrNull()
|
|
||||||
if (first == null) {
|
|
||||||
return TalkModeResolvedVoice(
|
|
||||||
voiceId = null,
|
|
||||||
fallbackVoiceId = fallbackVoiceId,
|
|
||||||
defaultVoiceId = defaultVoiceId,
|
|
||||||
currentVoiceId = currentVoiceId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return TalkModeResolvedVoice(
|
|
||||||
voiceId = first.voiceId,
|
|
||||||
fallbackVoiceId = first.voiceId,
|
|
||||||
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
|
|
||||||
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
|
|
||||||
selectedVoiceName = first.name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
|
||||||
val conn = url.openConnection() as HttpURLConnection
|
|
||||||
try {
|
|
||||||
conn.requestMethod = "GET"
|
|
||||||
conn.connectTimeout = 15_000
|
|
||||||
conn.readTimeout = 15_000
|
|
||||||
conn.setRequestProperty("xi-api-key", apiKey)
|
|
||||||
|
|
||||||
val code = conn.responseCode
|
|
||||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
|
||||||
val data = stream?.use { it.readBytes() } ?: byteArrayOf()
|
|
||||||
if (code >= 400) {
|
|
||||||
val message = data.toString(Charsets.UTF_8)
|
|
||||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
|
||||||
}
|
|
||||||
|
|
||||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
|
||||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
|
||||||
voices.mapNotNull { entry ->
|
|
||||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
|
||||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
|
||||||
val name = obj["name"].asStringOrNull()
|
|
||||||
ElevenLabsVoice(voiceId, name)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isLikelyVoiceId(value: String): Boolean {
|
|
||||||
if (value.length < 10) return false
|
|
||||||
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeAliasKey(value: String): String =
|
|
||||||
value.trim().lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? =
|
|
||||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
|
||||||
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SEND_SMS"
|
||||||
|
tools:node="remove" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_SMS"
|
||||||
|
tools:node="remove" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_CALL_LOG"
|
||||||
|
tools:node="remove" />
|
||||||
|
</manifest>
|
||||||
@ -26,7 +26,6 @@ class InvokeCommandRegistryTest {
|
|||||||
OpenClawCapability.Photos.rawValue,
|
OpenClawCapability.Photos.rawValue,
|
||||||
OpenClawCapability.Contacts.rawValue,
|
OpenClawCapability.Contacts.rawValue,
|
||||||
OpenClawCapability.Calendar.rawValue,
|
OpenClawCapability.Calendar.rawValue,
|
||||||
OpenClawCapability.CallLog.rawValue,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val optionalCapabilities =
|
private val optionalCapabilities =
|
||||||
@ -34,6 +33,7 @@ class InvokeCommandRegistryTest {
|
|||||||
OpenClawCapability.Camera.rawValue,
|
OpenClawCapability.Camera.rawValue,
|
||||||
OpenClawCapability.Location.rawValue,
|
OpenClawCapability.Location.rawValue,
|
||||||
OpenClawCapability.Sms.rawValue,
|
OpenClawCapability.Sms.rawValue,
|
||||||
|
OpenClawCapability.CallLog.rawValue,
|
||||||
OpenClawCapability.VoiceWake.rawValue,
|
OpenClawCapability.VoiceWake.rawValue,
|
||||||
OpenClawCapability.Motion.rawValue,
|
OpenClawCapability.Motion.rawValue,
|
||||||
)
|
)
|
||||||
@ -52,7 +52,6 @@ class InvokeCommandRegistryTest {
|
|||||||
OpenClawContactsCommand.Add.rawValue,
|
OpenClawContactsCommand.Add.rawValue,
|
||||||
OpenClawCalendarCommand.Events.rawValue,
|
OpenClawCalendarCommand.Events.rawValue,
|
||||||
OpenClawCalendarCommand.Add.rawValue,
|
OpenClawCalendarCommand.Add.rawValue,
|
||||||
OpenClawCallLogCommand.Search.rawValue,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val optionalCommands =
|
private val optionalCommands =
|
||||||
@ -64,6 +63,8 @@ class InvokeCommandRegistryTest {
|
|||||||
OpenClawMotionCommand.Activity.rawValue,
|
OpenClawMotionCommand.Activity.rawValue,
|
||||||
OpenClawMotionCommand.Pedometer.rawValue,
|
OpenClawMotionCommand.Pedometer.rawValue,
|
||||||
OpenClawSmsCommand.Send.rawValue,
|
OpenClawSmsCommand.Send.rawValue,
|
||||||
|
OpenClawSmsCommand.Search.rawValue,
|
||||||
|
OpenClawCallLogCommand.Search.rawValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||||
@ -83,7 +84,9 @@ class InvokeCommandRegistryTest {
|
|||||||
defaultFlags(
|
defaultFlags(
|
||||||
cameraEnabled = true,
|
cameraEnabled = true,
|
||||||
locationEnabled = true,
|
locationEnabled = true,
|
||||||
smsAvailable = true,
|
sendSmsAvailable = true,
|
||||||
|
readSmsAvailable = true,
|
||||||
|
callLogAvailable = true,
|
||||||
voiceWakeEnabled = true,
|
voiceWakeEnabled = true,
|
||||||
motionActivityAvailable = true,
|
motionActivityAvailable = true,
|
||||||
motionPedometerAvailable = true,
|
motionPedometerAvailable = true,
|
||||||
@ -108,7 +111,9 @@ class InvokeCommandRegistryTest {
|
|||||||
defaultFlags(
|
defaultFlags(
|
||||||
cameraEnabled = true,
|
cameraEnabled = true,
|
||||||
locationEnabled = true,
|
locationEnabled = true,
|
||||||
smsAvailable = true,
|
sendSmsAvailable = true,
|
||||||
|
readSmsAvailable = true,
|
||||||
|
callLogAvailable = true,
|
||||||
motionActivityAvailable = true,
|
motionActivityAvailable = true,
|
||||||
motionPedometerAvailable = true,
|
motionPedometerAvailable = true,
|
||||||
debugBuild = true,
|
debugBuild = true,
|
||||||
@ -125,7 +130,9 @@ class InvokeCommandRegistryTest {
|
|||||||
NodeRuntimeFlags(
|
NodeRuntimeFlags(
|
||||||
cameraEnabled = false,
|
cameraEnabled = false,
|
||||||
locationEnabled = false,
|
locationEnabled = false,
|
||||||
smsAvailable = false,
|
sendSmsAvailable = false,
|
||||||
|
readSmsAvailable = false,
|
||||||
|
callLogAvailable = false,
|
||||||
voiceWakeEnabled = false,
|
voiceWakeEnabled = false,
|
||||||
motionActivityAvailable = true,
|
motionActivityAvailable = true,
|
||||||
motionPedometerAvailable = false,
|
motionPedometerAvailable = false,
|
||||||
@ -137,10 +144,58 @@ class InvokeCommandRegistryTest {
|
|||||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
|
||||||
|
val readOnlyCommands =
|
||||||
|
InvokeCommandRegistry.advertisedCommands(
|
||||||
|
defaultFlags(readSmsAvailable = true),
|
||||||
|
)
|
||||||
|
val sendOnlyCommands =
|
||||||
|
InvokeCommandRegistry.advertisedCommands(
|
||||||
|
defaultFlags(sendSmsAvailable = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||||
|
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||||
|
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||||
|
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
|
||||||
|
val readOnlyCapabilities =
|
||||||
|
InvokeCommandRegistry.advertisedCapabilities(
|
||||||
|
defaultFlags(readSmsAvailable = true),
|
||||||
|
)
|
||||||
|
val sendOnlyCapabilities =
|
||||||
|
InvokeCommandRegistry.advertisedCapabilities(
|
||||||
|
defaultFlags(sendSmsAvailable = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||||
|
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisedCommands_excludesCallLogWhenUnavailable() {
|
||||||
|
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false))
|
||||||
|
|
||||||
|
assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advertisedCapabilities_excludesCallLogWhenUnavailable() {
|
||||||
|
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false))
|
||||||
|
|
||||||
|
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
private fun defaultFlags(
|
private fun defaultFlags(
|
||||||
cameraEnabled: Boolean = false,
|
cameraEnabled: Boolean = false,
|
||||||
locationEnabled: Boolean = false,
|
locationEnabled: Boolean = false,
|
||||||
smsAvailable: Boolean = false,
|
sendSmsAvailable: Boolean = false,
|
||||||
|
readSmsAvailable: Boolean = false,
|
||||||
|
callLogAvailable: Boolean = false,
|
||||||
voiceWakeEnabled: Boolean = false,
|
voiceWakeEnabled: Boolean = false,
|
||||||
motionActivityAvailable: Boolean = false,
|
motionActivityAvailable: Boolean = false,
|
||||||
motionPedometerAvailable: Boolean = false,
|
motionPedometerAvailable: Boolean = false,
|
||||||
@ -149,7 +204,9 @@ class InvokeCommandRegistryTest {
|
|||||||
NodeRuntimeFlags(
|
NodeRuntimeFlags(
|
||||||
cameraEnabled = cameraEnabled,
|
cameraEnabled = cameraEnabled,
|
||||||
locationEnabled = locationEnabled,
|
locationEnabled = locationEnabled,
|
||||||
smsAvailable = smsAvailable,
|
sendSmsAvailable = sendSmsAvailable,
|
||||||
|
readSmsAvailable = readSmsAvailable,
|
||||||
|
callLogAvailable = callLogAvailable,
|
||||||
voiceWakeEnabled = voiceWakeEnabled,
|
voiceWakeEnabled = voiceWakeEnabled,
|
||||||
motionActivityAvailable = motionActivityAvailable,
|
motionActivityAvailable = motionActivityAvailable,
|
||||||
motionPedometerAvailable = motionPedometerAvailable,
|
motionPedometerAvailable = motionPedometerAvailable,
|
||||||
|
|||||||
@ -0,0 +1,88 @@
|
|||||||
|
package ai.openclaw.app.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LocationHandlerTest : NodeHandlerRobolectricTest() {
|
||||||
|
@Test
|
||||||
|
fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() =
|
||||||
|
runTest {
|
||||||
|
val handler =
|
||||||
|
LocationHandler.forTesting(
|
||||||
|
appContext = appContext(),
|
||||||
|
dataSource =
|
||||||
|
FakeLocationDataSource(
|
||||||
|
fineGranted = false,
|
||||||
|
coarseGranted = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.handleLocationGet(null)
|
||||||
|
|
||||||
|
assertFalse(result.ok)
|
||||||
|
assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleLocationGet_requiresForegroundBeforeLocationPermission() =
|
||||||
|
runTest {
|
||||||
|
val handler =
|
||||||
|
LocationHandler.forTesting(
|
||||||
|
appContext = appContext(),
|
||||||
|
dataSource =
|
||||||
|
FakeLocationDataSource(
|
||||||
|
fineGranted = true,
|
||||||
|
coarseGranted = true,
|
||||||
|
),
|
||||||
|
isForeground = { false },
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.handleLocationGet(null)
|
||||||
|
|
||||||
|
assertFalse(result.ok)
|
||||||
|
assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasFineLocationPermission_reflectsDataSource() {
|
||||||
|
val denied =
|
||||||
|
LocationHandler.forTesting(
|
||||||
|
appContext = appContext(),
|
||||||
|
dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true),
|
||||||
|
)
|
||||||
|
assertFalse(denied.hasFineLocationPermission())
|
||||||
|
assertTrue(denied.hasCoarseLocationPermission())
|
||||||
|
|
||||||
|
val granted =
|
||||||
|
LocationHandler.forTesting(
|
||||||
|
appContext = appContext(),
|
||||||
|
dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false),
|
||||||
|
)
|
||||||
|
assertTrue(granted.hasFineLocationPermission())
|
||||||
|
assertFalse(granted.hasCoarseLocationPermission())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocationDataSource(
|
||||||
|
private val fineGranted: Boolean,
|
||||||
|
private val coarseGranted: Boolean,
|
||||||
|
) : LocationDataSource {
|
||||||
|
override fun hasFinePermission(context: Context): Boolean = fineGranted
|
||||||
|
|
||||||
|
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
|
||||||
|
|
||||||
|
override suspend fun fetchLocation(
|
||||||
|
desiredProviders: List<String>,
|
||||||
|
maxAgeMs: Long?,
|
||||||
|
timeoutMs: Long,
|
||||||
|
isPrecise: Boolean,
|
||||||
|
): LocationCaptureManager.Payload {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"LocationHandlerTest: fetchLocation must not run in this scenario",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -88,4 +88,95 @@ class SmsManagerTest {
|
|||||||
assertFalse(plan.useMultipart)
|
assertFalse(plan.useMultipart)
|
||||||
assertEquals(listOf("hello"), plan.parts)
|
assertEquals(listOf("hello"), plan.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsAcceptsEmptyPayload() {
|
||||||
|
val result = SmsManager.parseQueryParams(null, json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(25, ok.params.limit)
|
||||||
|
assertEquals(0, ok.params.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsRejectsInvalidJson() {
|
||||||
|
val result = SmsManager.parseQueryParams("not-json", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||||
|
val error = result as SmsManager.QueryParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsRejectsNonObjectJson() {
|
||||||
|
val result = SmsManager.parseQueryParams("[]", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||||
|
val error = result as SmsManager.QueryParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesLimitAndOffset() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(10, ok.params.limit)
|
||||||
|
assertEquals(5, ok.params.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsClampsLimitRange() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(200, ok.params.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesPhoneNumber() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals("+1234567890", ok.params.phoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesContactName() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals("lixuankai", ok.params.contactName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesKeyword() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals("test", ok.params.keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesTimeRange() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(1000L, ok.params.startTime)
|
||||||
|
assertEquals(2000L, ok.params.endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesType() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(1, ok.params.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQueryParamsParsesReadStatus() {
|
||||||
|
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
|
||||||
|
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.QueryParseResult.Ok
|
||||||
|
assertEquals(true, ok.params.isRead)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
|
|||||||
fun callLogCommandsUseStableStrings() {
|
fun callLogCommandsUseStableStrings() {
|
||||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun smsCommandsUseStableStrings() {
|
||||||
|
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
package ai.openclaw.app.voice
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class TalkConfigContractFixture(
|
|
||||||
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
|
|
||||||
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class SelectionCase(
|
|
||||||
val id: String,
|
|
||||||
val defaultProvider: String,
|
|
||||||
val payloadValid: Boolean,
|
|
||||||
val expectedSelection: ExpectedSelection? = null,
|
|
||||||
val talk: JsonObject,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ExpectedSelection(
|
|
||||||
val provider: String,
|
|
||||||
val normalizedPayload: Boolean,
|
|
||||||
val voiceId: String? = null,
|
|
||||||
val apiKey: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TimeoutCase(
|
|
||||||
val id: String,
|
|
||||||
val fallback: Long,
|
|
||||||
val expectedTimeoutMs: Long,
|
|
||||||
val talk: JsonObject,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class TalkModeConfigContractTest {
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun selectionFixtures() {
|
|
||||||
for (fixture in loadFixtures().selectionCases) {
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
|
|
||||||
val expected = fixture.expectedSelection
|
|
||||||
if (expected == null) {
|
|
||||||
assertNull(fixture.id, selection)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
assertNotNull(fixture.id, selection)
|
|
||||||
assertEquals(fixture.id, expected.provider, selection?.provider)
|
|
||||||
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
|
|
||||||
assertEquals(
|
|
||||||
fixture.id,
|
|
||||||
expected.voiceId,
|
|
||||||
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
fixture.id,
|
|
||||||
expected.apiKey,
|
|
||||||
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
|
|
||||||
)
|
|
||||||
assertEquals(fixture.id, true, fixture.payloadValid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun timeoutFixtures() {
|
|
||||||
for (fixture in loadFixtures().timeoutCases) {
|
|
||||||
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
|
|
||||||
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
|
|
||||||
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadFixtures(): TalkConfigContractFixture {
|
|
||||||
val fixturePath = findFixtureFile()
|
|
||||||
return json.decodeFromString(File(fixturePath).readText())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findFixtureFile(): String {
|
|
||||||
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
|
|
||||||
var current = File(startDir).absoluteFile
|
|
||||||
while (true) {
|
|
||||||
val candidate = File(current, "test-fixtures/talk-config-contract.json")
|
|
||||||
if (candidate.exists()) {
|
|
||||||
return candidate.absolutePath
|
|
||||||
}
|
|
||||||
current = current.parentFile ?: break
|
|
||||||
}
|
|
||||||
error("talk-config-contract.json not found from $startDir")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,135 +2,37 @@ package ai.openclaw.app.voice
|
|||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class TalkModeConfigParsingTest {
|
class TalkModeConfigParsingTest {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun prefersCanonicalResolvedTalkProviderPayload() {
|
fun readsMainSessionKeyAndInterruptFlag() {
|
||||||
val talk =
|
val config =
|
||||||
json.parseToJsonElement(
|
json.parseToJsonElement(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"resolved": {
|
"talk": {
|
||||||
"provider": "elevenlabs",
|
"interruptOnSpeech": true,
|
||||||
"config": {
|
"silenceTimeoutMs": 1800
|
||||||
"voiceId": "voice-resolved"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"provider": "elevenlabs",
|
"session": {
|
||||||
"providers": {
|
"mainKey": "voice-main"
|
||||||
"elevenlabs": {
|
|
||||||
"voiceId": "voice-normalized"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
.jsonObject
|
.jsonObject
|
||||||
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
val parsed = TalkModeGatewayConfigParser.parse(config)
|
||||||
assertNotNull(selection)
|
|
||||||
assertEquals("elevenlabs", selection?.provider)
|
|
||||||
assertTrue(selection?.normalizedPayload == true)
|
|
||||||
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
assertEquals("voice-main", parsed.mainSessionKey)
|
||||||
fun prefersNormalizedTalkProviderPayload() {
|
assertEquals(true, parsed.interruptOnSpeech)
|
||||||
val talk =
|
assertEquals(1800L, parsed.silenceTimeoutMs)
|
||||||
json.parseToJsonElement(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"provider": "elevenlabs",
|
|
||||||
"providers": {
|
|
||||||
"elevenlabs": {
|
|
||||||
"voiceId": "voice-normalized"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"voiceId": "voice-legacy"
|
|
||||||
}
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
.jsonObject
|
|
||||||
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
|
||||||
assertEquals(null, selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
|
|
||||||
val talk =
|
|
||||||
json.parseToJsonElement(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"provider": "acme",
|
|
||||||
"providers": {
|
|
||||||
"elevenlabs": {
|
|
||||||
"voiceId": "voice-normalized"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
.jsonObject
|
|
||||||
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
|
||||||
assertEquals(null, selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
|
|
||||||
val talk =
|
|
||||||
json.parseToJsonElement(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"acme": {
|
|
||||||
"voiceId": "voice-acme"
|
|
||||||
},
|
|
||||||
"elevenlabs": {
|
|
||||||
"voiceId": "voice-normalized"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
.jsonObject
|
|
||||||
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
|
||||||
assertEquals(null, selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
|
||||||
val legacyApiKey = "legacy-key" // pragma: allowlist secret
|
|
||||||
val talk =
|
|
||||||
buildJsonObject {
|
|
||||||
put("voiceId", "voice-legacy")
|
|
||||||
put("apiKey", legacyApiKey) // pragma: allowlist secret
|
|
||||||
}
|
|
||||||
|
|
||||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
|
||||||
assertNotNull(selection)
|
|
||||||
assertEquals("elevenlabs", selection?.provider)
|
|
||||||
assertTrue(selection?.normalizedPayload == false)
|
|
||||||
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
|
||||||
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsConfiguredSilenceTimeoutMs() {
|
|
||||||
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
|
|
||||||
|
|
||||||
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
package ai.openclaw.app.voice
|
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TalkModeVoiceResolverTest {
|
|
||||||
@Test
|
|
||||||
fun resolvesVoiceAliasCaseInsensitively() {
|
|
||||||
val resolved =
|
|
||||||
TalkModeVoiceResolver.resolveVoiceAlias(
|
|
||||||
" Clawd ",
|
|
||||||
mapOf("clawd" to "voice-123"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("voice-123", resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun acceptsDirectVoiceIds() {
|
|
||||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
|
|
||||||
|
|
||||||
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsUnknownAliases() {
|
|
||||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
|
|
||||||
|
|
||||||
assertNull(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
|
|
||||||
runBlocking {
|
|
||||||
var fetchCount = 0
|
|
||||||
|
|
||||||
val resolved =
|
|
||||||
TalkModeVoiceResolver.resolveVoiceId(
|
|
||||||
preferred = null,
|
|
||||||
fallbackVoiceId = "cached-voice",
|
|
||||||
defaultVoiceId = null,
|
|
||||||
currentVoiceId = null,
|
|
||||||
voiceOverrideActive = false,
|
|
||||||
listVoices = {
|
|
||||||
fetchCount += 1
|
|
||||||
emptyList()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("cached-voice", resolved.voiceId)
|
|
||||||
assertEquals(0, fetchCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
|
|
||||||
runBlocking {
|
|
||||||
val resolved =
|
|
||||||
TalkModeVoiceResolver.resolveVoiceId(
|
|
||||||
preferred = null,
|
|
||||||
fallbackVoiceId = null,
|
|
||||||
defaultVoiceId = null,
|
|
||||||
currentVoiceId = null,
|
|
||||||
voiceOverrideActive = false,
|
|
||||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("voice-1", resolved.voiceId)
|
|
||||||
assertEquals("voice-1", resolved.fallbackVoiceId)
|
|
||||||
assertEquals("voice-1", resolved.defaultVoiceId)
|
|
||||||
assertEquals("voice-1", resolved.currentVoiceId)
|
|
||||||
assertEquals("First", resolved.selectedVoiceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun preservesCurrentVoiceWhenOverrideIsActive() =
|
|
||||||
runBlocking {
|
|
||||||
val resolved =
|
|
||||||
TalkModeVoiceResolver.resolveVoiceId(
|
|
||||||
preferred = null,
|
|
||||||
fallbackVoiceId = null,
|
|
||||||
defaultVoiceId = null,
|
|
||||||
currentVoiceId = null,
|
|
||||||
voiceOverrideActive = true,
|
|
||||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("voice-1", resolved.voiceId)
|
|
||||||
assertNull(resolved.currentVoiceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "9.0.1" apply false
|
id("com.android.application") version "9.1.0" apply false
|
||||||
id("com.android.test") version "9.0.1" apply false
|
id("com.android.test") version "9.1.0" apply false
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false
|
id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@ -7,7 +7,28 @@ import { fileURLToPath } from "node:url";
|
|||||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const androidDir = join(scriptDir, "..");
|
const androidDir = join(scriptDir, "..");
|
||||||
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
|
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
|
||||||
const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab");
|
const releaseOutputDir = join(androidDir, "build", "release-bundles");
|
||||||
|
|
||||||
|
const releaseVariants = [
|
||||||
|
{
|
||||||
|
flavorName: "play",
|
||||||
|
gradleTask: ":app:bundlePlayRelease",
|
||||||
|
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flavorName: "third-party",
|
||||||
|
gradleTask: ":app:bundleThirdPartyRelease",
|
||||||
|
bundlePath: join(
|
||||||
|
androidDir,
|
||||||
|
"app",
|
||||||
|
"build",
|
||||||
|
"outputs",
|
||||||
|
"bundle",
|
||||||
|
"thirdPartyRelease",
|
||||||
|
"app-thirdParty-release.aab",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
type VersionState = {
|
type VersionState = {
|
||||||
versionName: string;
|
versionName: string;
|
||||||
@ -88,6 +109,15 @@ async function verifyBundleSignature(path: string): Promise<void> {
|
|||||||
await $`jarsigner -verify ${path}`.quiet();
|
await $`jarsigner -verify ${path}`.quiet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
|
||||||
|
const sourceFile = Bun.file(sourcePath);
|
||||||
|
if (!(await sourceFile.exists())) {
|
||||||
|
throw new Error(`Signed bundle missing at ${sourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(destinationPath, sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const buildGradleFile = Bun.file(buildGradlePath);
|
const buildGradleFile = Bun.file(buildGradlePath);
|
||||||
const originalText = await buildGradleFile.text();
|
const originalText = await buildGradleFile.text();
|
||||||
@ -102,24 +132,28 @@ async function main() {
|
|||||||
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
|
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
|
||||||
|
|
||||||
await Bun.write(buildGradlePath, updatedText);
|
await Bun.write(buildGradlePath, updatedText);
|
||||||
|
await $`mkdir -p ${releaseOutputDir}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $`./gradlew :app:bundleRelease`.cwd(androidDir);
|
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Bun.write(buildGradlePath, originalText);
|
await Bun.write(buildGradlePath, originalText);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundleFile = Bun.file(bundlePath);
|
for (const variant of releaseVariants) {
|
||||||
if (!(await bundleFile.exists())) {
|
const outputPath = join(
|
||||||
throw new Error(`Signed bundle missing at ${bundlePath}`);
|
releaseOutputDir,
|
||||||
|
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await copyBundle(variant.bundlePath, outputPath);
|
||||||
|
await verifyBundleSignature(outputPath);
|
||||||
|
const hash = await sha256Hex(outputPath);
|
||||||
|
|
||||||
|
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
|
||||||
|
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await verifyBundleSignature(bundlePath);
|
|
||||||
const hash = await sha256Hex(bundlePath);
|
|
||||||
|
|
||||||
console.log(`Signed AAB: ${bundlePath}`);
|
|
||||||
console.log(`SHA-256: ${hash}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
|
|||||||
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
@ -0,0 +1,430 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||||
|
|
||||||
|
PACKAGE="ai.openclaw.app"
|
||||||
|
ACTIVITY=".MainActivity"
|
||||||
|
DEVICE_SERIAL=""
|
||||||
|
INSTALL_APP="1"
|
||||||
|
LAUNCH_RUNS="4"
|
||||||
|
SCREEN_LOOPS="6"
|
||||||
|
CHAT_LOOPS="8"
|
||||||
|
POLL_ATTEMPTS="40"
|
||||||
|
POLL_INTERVAL_SECONDS="0.3"
|
||||||
|
SCREEN_MODE="transition"
|
||||||
|
CHAT_MODE="session-switch"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/perf-online-benchmark.sh [options]
|
||||||
|
|
||||||
|
Measures the fully-online Android app path on a connected device/emulator.
|
||||||
|
Assumes the app can reach a live gateway and will show "Connected" in the UI.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--device <serial> adb device serial
|
||||||
|
--package <pkg> package name (default: ai.openclaw.app)
|
||||||
|
--activity <activity> launch activity (default: .MainActivity)
|
||||||
|
--skip-install skip :app:installDebug
|
||||||
|
--launch-runs <n> launch-to-connected runs (default: 4)
|
||||||
|
--screen-loops <n> screen benchmark loops (default: 6)
|
||||||
|
--chat-loops <n> chat benchmark loops (default: 8)
|
||||||
|
--screen-mode <mode> transition | scroll (default: transition)
|
||||||
|
--chat-mode <mode> session-switch | scroll (default: session-switch)
|
||||||
|
-h, --help show help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--device)
|
||||||
|
DEVICE_SERIAL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--package)
|
||||||
|
PACKAGE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--activity)
|
||||||
|
ACTIVITY="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-install)
|
||||||
|
INSTALL_APP="0"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--launch-runs)
|
||||||
|
LAUNCH_RUNS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--screen-loops)
|
||||||
|
SCREEN_LOOPS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--chat-loops)
|
||||||
|
CHAT_LOOPS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--screen-mode)
|
||||||
|
SCREEN_MODE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--chat-mode)
|
||||||
|
CHAT_MODE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown arg: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "$1 required but missing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd adb
|
||||||
|
require_cmd awk
|
||||||
|
require_cmd rg
|
||||||
|
require_cmd node
|
||||||
|
|
||||||
|
adb_cmd() {
|
||||||
|
if [[ -n "$DEVICE_SERIAL" ]]; then
|
||||||
|
adb -s "$DEVICE_SERIAL" "$@"
|
||||||
|
else
|
||||||
|
adb "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||||
|
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
|
||||||
|
echo "No connected Android device (adb state=device)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
|
||||||
|
echo "Multiple adb devices found. Pass --device <serial>." >&2
|
||||||
|
adb devices -l >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
|
||||||
|
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
|
||||||
|
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$RESULTS_DIR"
|
||||||
|
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
run_dir="$RESULTS_DIR/online-$timestamp"
|
||||||
|
mkdir -p "$run_dir"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$run_dir"/ui-*.xml
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ "$INSTALL_APP" == "1" ]]; then
|
||||||
|
(
|
||||||
|
cd "$ANDROID_DIR"
|
||||||
|
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r display_width display_height <<<"$(
|
||||||
|
adb_cmd shell wm size \
|
||||||
|
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
|
||||||
|
echo "Failed to read device display size." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pct_of() {
|
||||||
|
local total="$1"
|
||||||
|
local pct="$2"
|
||||||
|
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
|
||||||
|
}
|
||||||
|
|
||||||
|
tab_connect_x="$(pct_of "$display_width" "0.11")"
|
||||||
|
tab_chat_x="$(pct_of "$display_width" "0.31")"
|
||||||
|
tab_screen_x="$(pct_of "$display_width" "0.69")"
|
||||||
|
tab_y="$(pct_of "$display_height" "0.93")"
|
||||||
|
chat_session_y="$(pct_of "$display_height" "0.13")"
|
||||||
|
chat_session_left_x="$(pct_of "$display_width" "0.16")"
|
||||||
|
chat_session_right_x="$(pct_of "$display_width" "0.85")"
|
||||||
|
center_x="$(pct_of "$display_width" "0.50")"
|
||||||
|
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
|
||||||
|
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||||
|
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
|
||||||
|
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
|
||||||
|
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
|
||||||
|
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||||
|
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
|
||||||
|
|
||||||
|
dump_ui() {
|
||||||
|
local name="$1"
|
||||||
|
local file="$run_dir/ui-$name.xml"
|
||||||
|
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
|
||||||
|
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
|
||||||
|
printf '%s\n' "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_has() {
|
||||||
|
local pattern="$1"
|
||||||
|
local name="$2"
|
||||||
|
local file
|
||||||
|
file="$(dump_ui "$name")"
|
||||||
|
rg -q "$pattern" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_pattern() {
|
||||||
|
local pattern="$1"
|
||||||
|
local prefix="$2"
|
||||||
|
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||||
|
if ui_has "$pattern" "$prefix-$attempt"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep "$POLL_INTERVAL_SECONDS"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_connected() {
|
||||||
|
if ! wait_for_pattern 'text="Connected"' "connected"; then
|
||||||
|
echo "App never reached visible Connected state." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_screen_online() {
|
||||||
|
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||||
|
sleep 2
|
||||||
|
if ! ui_has 'android\.webkit\.WebView' "screen"; then
|
||||||
|
echo "Screen benchmark expected a live WebView." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_chat_online() {
|
||||||
|
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||||
|
sleep 2
|
||||||
|
if ! ui_has 'Type a message' "chat"; then
|
||||||
|
echo "Chat benchmark expected the live chat composer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_mem() {
|
||||||
|
local file="$1"
|
||||||
|
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_cpu_sampler() {
|
||||||
|
local file="$1"
|
||||||
|
local samples="$2"
|
||||||
|
: >"$file"
|
||||||
|
(
|
||||||
|
for _ in $(seq 1 "$samples"); do
|
||||||
|
adb_cmd shell top -b -n 1 \
|
||||||
|
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
CPU_SAMPLER_PID="$!"
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize_cpu() {
|
||||||
|
local file="$1"
|
||||||
|
local prefix="$2"
|
||||||
|
local avg max median count
|
||||||
|
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
|
||||||
|
max="$(sort -n "$file" | tail -n 1)"
|
||||||
|
median="$(
|
||||||
|
sort -n "$file" \
|
||||||
|
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
|
||||||
|
)"
|
||||||
|
count="$(wc -l <"$file" | tr -d ' ')"
|
||||||
|
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
|
||||||
|
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
|
||||||
|
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
|
||||||
|
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize_mem() {
|
||||||
|
local file="$1"
|
||||||
|
local prefix="$2"
|
||||||
|
awk -v prefix="$prefix" '
|
||||||
|
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
|
||||||
|
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
|
||||||
|
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
|
||||||
|
' "$file" >>"$run_dir/summary.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize_gfx() {
|
||||||
|
local file="$1"
|
||||||
|
local prefix="$2"
|
||||||
|
awk -v prefix="$prefix" '
|
||||||
|
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
|
||||||
|
/Janky frames:/ && $4 ~ /\(/ {
|
||||||
|
pct=$4
|
||||||
|
gsub(/[()%]/, "", pct)
|
||||||
|
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
|
||||||
|
}
|
||||||
|
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
|
||||||
|
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
|
||||||
|
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
|
||||||
|
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
|
||||||
|
' "$file" >>"$run_dir/summary.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
measure_launch() {
|
||||||
|
: >"$run_dir/launch-runs.txt"
|
||||||
|
for run in $(seq 1 "$LAUNCH_RUNS"); do
|
||||||
|
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
start_ms="$(node -e 'console.log(Date.now())')"
|
||||||
|
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
|
||||||
|
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
|
||||||
|
connected_ms="timeout"
|
||||||
|
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||||
|
if ui_has 'text="Connected"' "launch-run-$run"; then
|
||||||
|
now_ms="$(node -e 'console.log(Date.now())')"
|
||||||
|
connected_ms="$((now_ms - start_ms))"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep "$POLL_INTERVAL_SECONDS"
|
||||||
|
done
|
||||||
|
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
|
||||||
|
| tee -a "$run_dir/launch-runs.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
awk -F'[ =]' '
|
||||||
|
/total_time_ms=[0-9]+/ {
|
||||||
|
value=$4
|
||||||
|
sum+=value
|
||||||
|
count+=1
|
||||||
|
if (min==0 || value<min) min=value
|
||||||
|
if (value>max) max=value
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (count==0) exit
|
||||||
|
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
|
||||||
|
}
|
||||||
|
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||||
|
|
||||||
|
awk -F'[ =]' '
|
||||||
|
/connected_ms=[0-9]+/ {
|
||||||
|
value=$6
|
||||||
|
sum+=value
|
||||||
|
count+=1
|
||||||
|
if (min==0 || value<min) min=value
|
||||||
|
if (value>max) max=value
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (count==0) exit
|
||||||
|
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
|
||||||
|
}
|
||||||
|
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_screen_benchmark() {
|
||||||
|
ensure_screen_online
|
||||||
|
capture_mem "$run_dir/screen-mem-before.txt"
|
||||||
|
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||||
|
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
|
||||||
|
|
||||||
|
if [[ "$SCREEN_MODE" == "transition" ]]; then
|
||||||
|
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||||
|
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||||
|
sleep 1.0
|
||||||
|
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||||
|
sleep 0.8
|
||||||
|
done
|
||||||
|
else
|
||||||
|
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||||
|
sleep 1.5
|
||||||
|
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||||
|
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
|
||||||
|
sleep 0.35
|
||||||
|
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
|
||||||
|
sleep 0.35
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait "$CPU_SAMPLER_PID"
|
||||||
|
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
|
||||||
|
capture_mem "$run_dir/screen-mem-after.txt"
|
||||||
|
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
|
||||||
|
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
|
||||||
|
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
|
||||||
|
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_chat_benchmark() {
|
||||||
|
ensure_chat_online
|
||||||
|
capture_mem "$run_dir/chat-mem-before.txt"
|
||||||
|
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||||
|
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
|
||||||
|
|
||||||
|
if [[ "$CHAT_MODE" == "session-switch" ]]; then
|
||||||
|
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||||
|
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
|
||||||
|
sleep 0.8
|
||||||
|
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
|
||||||
|
sleep 0.8
|
||||||
|
done
|
||||||
|
else
|
||||||
|
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||||
|
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
|
||||||
|
sleep 0.35
|
||||||
|
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
|
||||||
|
sleep 0.35
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait "$CPU_SAMPLER_PID"
|
||||||
|
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
|
||||||
|
capture_mem "$run_dir/chat-mem-after.txt"
|
||||||
|
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
|
||||||
|
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
|
||||||
|
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
|
||||||
|
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
|
||||||
|
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
|
||||||
|
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
|
||||||
|
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
|
||||||
|
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
|
||||||
|
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
|
||||||
|
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
|
||||||
|
|
||||||
|
ensure_connected
|
||||||
|
measure_launch
|
||||||
|
ensure_connected
|
||||||
|
run_screen_benchmark
|
||||||
|
ensure_connected
|
||||||
|
run_chat_benchmark
|
||||||
|
|
||||||
|
printf 'results_dir=%s\n' "$run_dir"
|
||||||
|
cat "$run_dir/summary.txt"
|
||||||
@ -174,7 +174,12 @@ final class GatewayConnectionController {
|
|||||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
if resolvedUseTLS, stored == nil {
|
if resolvedUseTLS, stored == nil {
|
||||||
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
||||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||||
|
self.appModel?.gatewayStatusText =
|
||||||
|
"TLS handshake failed for \(host):\(resolvedPort). "
|
||||||
|
+ "Remote gateways must use HTTPS/WSS."
|
||||||
|
return
|
||||||
|
}
|
||||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||||
self.pendingTrustPrompt = TrustPrompt(
|
self.pendingTrustPrompt = TrustPrompt(
|
||||||
stableID: stableID,
|
stableID: stableID,
|
||||||
|
|||||||
@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
|
|||||||
private var authStep: some View {
|
private var authStep: some View {
|
||||||
Group {
|
Group {
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||||
@ -724,6 +724,12 @@ struct OnboardingWizardView: View {
|
|||||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
if self.selectedMode == .remoteDomain {
|
||||||
|
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||||
|
}
|
||||||
self.manualConnectButton
|
self.manualConnectButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
|
|||||||
let env: [String: String]
|
let env: [String: String]
|
||||||
let resolution: ExecCommandResolution?
|
let resolution: ExecCommandResolution?
|
||||||
let allowlistResolutions: [ExecCommandResolution]
|
let allowlistResolutions: [ExecCommandResolution]
|
||||||
|
let allowAlwaysPatterns: [String]
|
||||||
let allowlistMatches: [ExecAllowlistEntry]
|
let allowlistMatches: [ExecAllowlistEntry]
|
||||||
let allowlistSatisfied: Bool
|
let allowlistSatisfied: Bool
|
||||||
let allowlistMatch: ExecAllowlistEntry?
|
let allowlistMatch: ExecAllowlistEntry?
|
||||||
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
|
|||||||
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
||||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
||||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||||
|
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
|
||||||
|
command: command,
|
||||||
|
rawCommand: rawCommand)
|
||||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
command: command,
|
command: command,
|
||||||
rawCommand: rawCommand,
|
rawCommand: allowlistRawCommand,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env)
|
||||||
|
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||||
|
command: command,
|
||||||
cwd: cwd,
|
cwd: cwd,
|
||||||
env: env)
|
env: env)
|
||||||
let allowlistMatches = security == .allowlist
|
let allowlistMatches = security == .allowlist
|
||||||
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
|
|||||||
env: env,
|
env: env,
|
||||||
resolution: allowlistResolutions.first,
|
resolution: allowlistResolutions.first,
|
||||||
allowlistResolutions: allowlistResolutions,
|
allowlistResolutions: allowlistResolutions,
|
||||||
|
allowAlwaysPatterns: allowAlwaysPatterns,
|
||||||
allowlistMatches: allowlistMatches,
|
allowlistMatches: allowlistMatches,
|
||||||
allowlistSatisfied: allowlistSatisfied,
|
allowlistSatisfied: allowlistSatisfied,
|
||||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||||
|
|||||||
@ -378,7 +378,7 @@ private enum ExecHostExecutor {
|
|||||||
let context = await self.buildContext(
|
let context = await self.buildContext(
|
||||||
request: request,
|
request: request,
|
||||||
command: validatedRequest.command,
|
command: validatedRequest.command,
|
||||||
rawCommand: validatedRequest.displayCommand)
|
rawCommand: validatedRequest.evaluationRawCommand)
|
||||||
|
|
||||||
switch ExecHostRequestEvaluator.evaluate(
|
switch ExecHostRequestEvaluator.evaluate(
|
||||||
context: context,
|
context: context,
|
||||||
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
|
|||||||
{
|
{
|
||||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
var seenPatterns = Set<String>()
|
var seenPatterns = Set<String>()
|
||||||
for candidate in context.allowlistResolutions {
|
for pattern in context.allowAlwaysPatterns {
|
||||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
|
||||||
command: context.command,
|
|
||||||
resolution: candidate)
|
|
||||||
else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seenPatterns.insert(pattern).inserted {
|
if seenPatterns.insert(pattern).inserted {
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,23 @@ struct ExecCommandResolution {
|
|||||||
return [resolution]
|
return [resolution]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func resolveAllowAlwaysPatterns(
|
||||||
|
command: [String],
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?) -> [String]
|
||||||
|
{
|
||||||
|
var patterns: [String] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
self.collectAllowAlwaysPatterns(
|
||||||
|
command: command,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
depth: 0,
|
||||||
|
patterns: &patterns,
|
||||||
|
seen: &seen)
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
@ -101,6 +118,115 @@ struct ExecCommandResolution {
|
|||||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func collectAllowAlwaysPatterns(
|
||||||
|
command: [String],
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?,
|
||||||
|
depth: Int,
|
||||||
|
patterns: inout [String],
|
||||||
|
seen: inout Set<String>)
|
||||||
|
{
|
||||||
|
guard depth < 3, !command.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
ExecCommandToken.basenameLower(token0) == "env",
|
||||||
|
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||||
|
!envUnwrapped.isEmpty
|
||||||
|
{
|
||||||
|
self.collectAllowAlwaysPatterns(
|
||||||
|
command: envUnwrapped,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
depth: depth + 1,
|
||||||
|
patterns: &patterns,
|
||||||
|
seen: &seen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||||
|
self.collectAllowAlwaysPatterns(
|
||||||
|
command: shellMultiplexer,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
depth: depth + 1,
|
||||||
|
patterns: &patterns,
|
||||||
|
seen: &seen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||||
|
if shell.isWrapper {
|
||||||
|
guard let shellCommand = shell.command,
|
||||||
|
let segments = self.splitShellCommandChain(shellCommand)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for segment in segments {
|
||||||
|
let tokens = self.tokenizeShellWords(segment)
|
||||||
|
guard !tokens.isEmpty else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
self.collectAllowAlwaysPatterns(
|
||||||
|
command: tokens,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
depth: depth + 1,
|
||||||
|
patterns: &patterns,
|
||||||
|
seen: &seen)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||||
|
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||||
|
seen.insert(pattern).inserted
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patterns.append(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||||
|
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let wrapper = ExecCommandToken.basenameLower(token0)
|
||||||
|
guard wrapper == "busybox" || wrapper == "toybox" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var appletIndex = 1
|
||||||
|
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||||
|
appletIndex += 1
|
||||||
|
}
|
||||||
|
guard appletIndex < argv.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !applet.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
||||||
|
let shellWrappers = Set([
|
||||||
|
"ash",
|
||||||
|
"bash",
|
||||||
|
"dash",
|
||||||
|
"fish",
|
||||||
|
"ksh",
|
||||||
|
"powershell",
|
||||||
|
"pwsh",
|
||||||
|
"sh",
|
||||||
|
"zsh",
|
||||||
|
])
|
||||||
|
guard shellWrappers.contains(normalizedApplet) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Array(argv[appletIndex...])
|
||||||
|
}
|
||||||
|
|
||||||
private static func parseFirstToken(_ command: String) -> String? {
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|||||||
@ -12,14 +12,24 @@ enum ExecCommandToken {
|
|||||||
enum ExecEnvInvocationUnwrapper {
|
enum ExecEnvInvocationUnwrapper {
|
||||||
static let maxWrapperDepth = 4
|
static let maxWrapperDepth = 4
|
||||||
|
|
||||||
|
struct UnwrapResult {
|
||||||
|
let command: [String]
|
||||||
|
let usesModifiers: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||||
return token.range(of: pattern, options: .regularExpression) != nil
|
return token.range(of: pattern, options: .regularExpression) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func unwrap(_ command: [String]) -> [String]? {
|
static func unwrap(_ command: [String]) -> [String]? {
|
||||||
|
self.unwrapWithMetadata(command)?.command
|
||||||
|
}
|
||||||
|
|
||||||
|
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
|
||||||
var idx = 1
|
var idx = 1
|
||||||
var expectsOptionValue = false
|
var expectsOptionValue = false
|
||||||
|
var usesModifiers = false
|
||||||
while idx < command.count {
|
while idx < command.count {
|
||||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if token.isEmpty {
|
if token.isEmpty {
|
||||||
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
}
|
}
|
||||||
if expectsOptionValue {
|
if expectsOptionValue {
|
||||||
expectsOptionValue = false
|
expectsOptionValue = false
|
||||||
|
usesModifiers = true
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if self.isEnvAssignment(token) {
|
if self.isEnvAssignment(token) {
|
||||||
|
usesModifiers = true
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
let lower = token.lowercased()
|
let lower = token.lowercased()
|
||||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||||
|
usesModifiers = true
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ExecEnvOptions.withValue.contains(flag) {
|
if ExecEnvOptions.withValue.contains(flag) {
|
||||||
|
usesModifiers = true
|
||||||
if !lower.contains("=") {
|
if !lower.contains("=") {
|
||||||
expectsOptionValue = true
|
expectsOptionValue = true
|
||||||
}
|
}
|
||||||
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
lower.hasPrefix("--ignore-signal=") ||
|
lower.hasPrefix("--ignore-signal=") ||
|
||||||
lower.hasPrefix("--block-signal=")
|
lower.hasPrefix("--block-signal=")
|
||||||
{
|
{
|
||||||
|
usesModifiers = true
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
guard idx < command.count else { return nil }
|
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||||
return Array(command[idx...])
|
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||||
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
|
|||||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
current = unwrapped
|
if unwrapped.usesModifiers {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = unwrapped.command
|
||||||
depth += 1
|
depth += 1
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Foundation
|
|||||||
struct ExecHostValidatedRequest {
|
struct ExecHostValidatedRequest {
|
||||||
let command: [String]
|
let command: [String]
|
||||||
let displayCommand: String
|
let displayCommand: String
|
||||||
|
let evaluationRawCommand: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecHostPolicyDecision {
|
enum ExecHostPolicyDecision {
|
||||||
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
|
|||||||
rawCommand: request.rawCommand)
|
rawCommand: request.rawCommand)
|
||||||
switch validatedCommand {
|
switch validatedCommand {
|
||||||
case let .ok(resolved):
|
case let .ok(resolved):
|
||||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
return .success(ExecHostValidatedRequest(
|
||||||
|
command: command,
|
||||||
|
displayCommand: resolved.displayCommand,
|
||||||
|
evaluationRawCommand: resolved.evaluationRawCommand))
|
||||||
case let .invalid(message):
|
case let .invalid(message):
|
||||||
return .failure(
|
return .failure(
|
||||||
ExecHostError(
|
ExecHostError(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum ExecSystemRunCommandValidator {
|
enum ExecSystemRunCommandValidator {
|
||||||
struct ResolvedCommand {
|
struct ResolvedCommand {
|
||||||
let displayCommand: String
|
let displayCommand: String
|
||||||
|
let evaluationRawCommand: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ValidationResult {
|
enum ValidationResult {
|
||||||
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
|
|||||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||||
|
let formattedArgv = ExecCommandFormatter.displayString(for: command)
|
||||||
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
|
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||||
shellCommand
|
shellCommand
|
||||||
} else {
|
} else {
|
||||||
ExecCommandFormatter.displayString(for: command)
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let raw = normalizedRaw, raw != inferred {
|
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
|
||||||
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
||||||
}
|
}
|
||||||
|
|
||||||
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
|
return .ok(ResolvedCommand(
|
||||||
|
displayCommand: formattedArgv,
|
||||||
|
evaluationRawCommand: self.allowlistEvaluationRawCommand(
|
||||||
|
normalizedRaw: normalizedRaw,
|
||||||
|
shellIsWrapper: shell.isWrapper,
|
||||||
|
previewCommand: previewCommand)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
|
||||||
|
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||||
|
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||||
|
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
|
||||||
|
|
||||||
|
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||||
|
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||||
|
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||||
|
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||||
|
shellCommand
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.allowlistEvaluationRawCommand(
|
||||||
|
normalizedRaw: normalizedRaw,
|
||||||
|
shellIsWrapper: shell.isWrapper,
|
||||||
|
previewCommand: previewCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
||||||
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func allowlistEvaluationRawCommand(
|
||||||
|
normalizedRaw: String?,
|
||||||
|
shellIsWrapper: Bool,
|
||||||
|
previewCommand: String?) -> String?
|
||||||
|
{
|
||||||
|
guard shellIsWrapper else {
|
||||||
|
return normalizedRaw
|
||||||
|
}
|
||||||
|
guard let normalizedRaw else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||||
|
}
|
||||||
|
|
||||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||||
let base = ExecCommandToken.basenameLower(token)
|
let base = ExecCommandToken.basenameLower(token)
|
||||||
if base.hasSuffix(".exe") {
|
if base.hasSuffix(".exe") {
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct HostEnvOverrideDiagnostics: Equatable {
|
||||||
|
var blockedKeys: [String]
|
||||||
|
var invalidKeys: [String]
|
||||||
|
}
|
||||||
|
|
||||||
enum HostEnvSanitizer {
|
enum HostEnvSanitizer {
|
||||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||||
@ -41,6 +46,67 @@ enum HostEnvSanitizer {
|
|||||||
return filtered.isEmpty ? nil : filtered
|
return filtered.isEmpty ? nil : filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
|
||||||
|
let value = scalar.value
|
||||||
|
return value == 95 || (65...90).contains(value) || (97...122).contains(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
|
||||||
|
let value = scalar.value
|
||||||
|
return self.isPortableHead(scalar) || (48...57).contains(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeOverrideKey(_ rawKey: String) -> String? {
|
||||||
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { return nil }
|
||||||
|
guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for scalar in key.unicodeScalars.dropFirst() {
|
||||||
|
if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sortedUnique(_ values: [String]) -> [String] {
|
||||||
|
Array(Set(values)).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func inspectOverrides(
|
||||||
|
overrides: [String: String]?,
|
||||||
|
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
|
||||||
|
{
|
||||||
|
guard let overrides else {
|
||||||
|
return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocked: [String] = []
|
||||||
|
var invalid: [String] = []
|
||||||
|
for (rawKey, _) in overrides {
|
||||||
|
let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let normalized = self.normalizeOverrideKey(rawKey) else {
|
||||||
|
invalid.append(candidate.isEmpty ? rawKey : candidate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let upper = normalized.uppercased()
|
||||||
|
if blockPathOverrides, upper == "PATH" {
|
||||||
|
blocked.append(upper)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if self.isBlockedOverride(upper) || self.isBlocked(upper) {
|
||||||
|
blocked.append(upper)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostEnvOverrideDiagnostics(
|
||||||
|
blockedKeys: self.sortedUnique(blocked),
|
||||||
|
invalidKeys: self.sortedUnique(invalid))
|
||||||
|
}
|
||||||
|
|
||||||
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
|
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
|
||||||
var merged: [String: String] = [:]
|
var merged: [String: String] = [:]
|
||||||
for (rawKey, value) in ProcessInfo.processInfo.environment {
|
for (rawKey, value) in ProcessInfo.processInfo.environment {
|
||||||
@ -57,8 +123,7 @@ enum HostEnvSanitizer {
|
|||||||
|
|
||||||
guard let effectiveOverrides else { return merged }
|
guard let effectiveOverrides else { return merged }
|
||||||
for (rawKey, value) in effectiveOverrides {
|
for (rawKey, value) in effectiveOverrides {
|
||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let key = self.normalizeOverrideKey(rawKey) else { continue }
|
||||||
guard !key.isEmpty else { continue }
|
|
||||||
let upper = key.uppercased()
|
let upper = key.uppercased()
|
||||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
||||||
// allow request-scoped PATH overrides from agents/gateways.
|
// allow request-scoped PATH overrides from agents/gateways.
|
||||||
|
|||||||
@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
|
|||||||
"OPENSSL_ENGINES",
|
"OPENSSL_ENGINES",
|
||||||
"PYTHONSTARTUP",
|
"PYTHONSTARTUP",
|
||||||
"WGETRC",
|
"WGETRC",
|
||||||
"CURL_HOME"
|
"CURL_HOME",
|
||||||
|
"CLASSPATH",
|
||||||
|
"CGO_CFLAGS",
|
||||||
|
"CGO_LDFLAGS",
|
||||||
|
"GOFLAGS",
|
||||||
|
"CORECLR_PROFILER_PATH",
|
||||||
|
"PHPRC",
|
||||||
|
"PHP_INI_SCAN_DIR",
|
||||||
|
"DENO_DIR",
|
||||||
|
"BUN_CONFIG_REGISTRY",
|
||||||
|
"LUA_PATH",
|
||||||
|
"LUA_CPATH",
|
||||||
|
"GEM_HOME",
|
||||||
|
"GEM_PATH",
|
||||||
|
"BUNDLE_GEMFILE",
|
||||||
|
"COMPOSER_HOME",
|
||||||
|
"XDG_CONFIG_HOME"
|
||||||
]
|
]
|
||||||
|
|
||||||
static let blockedOverridePrefixes: [String] = [
|
static let blockedOverridePrefixes: [String] = [
|
||||||
|
|||||||
@ -465,6 +465,23 @@ actor MacNodeRuntime {
|
|||||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
: self.mainSessionKey
|
: self.mainSessionKey
|
||||||
let runId = UUID().uuidString
|
let runId = UUID().uuidString
|
||||||
|
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
|
||||||
|
overrides: params.env,
|
||||||
|
blockPathOverrides: true)
|
||||||
|
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||||
|
var details: [String] = []
|
||||||
|
if !envOverrideDiagnostics.blockedKeys.isEmpty {
|
||||||
|
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
if !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||||
|
details.append(
|
||||||
|
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
|
||||||
|
}
|
||||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||||
command: command,
|
command: command,
|
||||||
rawCommand: params.rawCommand,
|
rawCommand: params.rawCommand,
|
||||||
@ -507,8 +524,7 @@ actor MacNodeRuntime {
|
|||||||
persistAllowlist: persistAllowlist,
|
persistAllowlist: persistAllowlist,
|
||||||
security: evaluation.security,
|
security: evaluation.security,
|
||||||
agentId: evaluation.agentId,
|
agentId: evaluation.agentId,
|
||||||
command: command,
|
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
|
||||||
allowlistResolutions: evaluation.allowlistResolutions)
|
|
||||||
|
|
||||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
@ -795,15 +811,11 @@ extension MacNodeRuntime {
|
|||||||
persistAllowlist: Bool,
|
persistAllowlist: Bool,
|
||||||
security: ExecSecurity,
|
security: ExecSecurity,
|
||||||
agentId: String?,
|
agentId: String?,
|
||||||
command: [String],
|
allowAlwaysPatterns: [String])
|
||||||
allowlistResolutions: [ExecCommandResolution])
|
|
||||||
{
|
{
|
||||||
guard persistAllowlist, security == .allowlist else { return }
|
guard persistAllowlist, security == .allowlist else { return }
|
||||||
var seenPatterns = Set<String>()
|
var seenPatterns = Set<String>()
|
||||||
for candidate in allowlistResolutions {
|
for pattern in allowAlwaysPatterns {
|
||||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seenPatterns.insert(pattern).inserted {
|
if seenPatterns.insert(pattern).inserted {
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SessionsCreateParams: Codable, Sendable {
|
||||||
|
public let key: String?
|
||||||
|
public let agentid: String?
|
||||||
|
public let label: String?
|
||||||
|
public let model: String?
|
||||||
|
public let parentsessionkey: String?
|
||||||
|
public let task: String?
|
||||||
|
public let message: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String?,
|
||||||
|
agentid: String?,
|
||||||
|
label: String?,
|
||||||
|
model: String?,
|
||||||
|
parentsessionkey: String?,
|
||||||
|
task: String?,
|
||||||
|
message: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.agentid = agentid
|
||||||
|
self.label = label
|
||||||
|
self.model = model
|
||||||
|
self.parentsessionkey = parentsessionkey
|
||||||
|
self.task = task
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case agentid = "agentId"
|
||||||
|
case label
|
||||||
|
case model
|
||||||
|
case parentsessionkey = "parentSessionKey"
|
||||||
|
case task
|
||||||
|
case message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsSendParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let message: String
|
||||||
|
public let thinking: String?
|
||||||
|
public let attachments: [AnyCodable]?
|
||||||
|
public let timeoutms: Int?
|
||||||
|
public let idempotencykey: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String,
|
||||||
|
message: String,
|
||||||
|
thinking: String?,
|
||||||
|
attachments: [AnyCodable]?,
|
||||||
|
timeoutms: Int?,
|
||||||
|
idempotencykey: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.message = message
|
||||||
|
self.thinking = thinking
|
||||||
|
self.attachments = attachments
|
||||||
|
self.timeoutms = timeoutms
|
||||||
|
self.idempotencykey = idempotencykey
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case message
|
||||||
|
case thinking
|
||||||
|
case attachments
|
||||||
|
case timeoutms = "timeoutMs"
|
||||||
|
case idempotencykey = "idempotencyKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsAbortParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let runid: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String,
|
||||||
|
runid: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.runid = runid
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case runid = "runId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SessionsPatchParams: Codable, Sendable {
|
public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let label: AnyCodable?
|
public let label: AnyCodable?
|
||||||
@ -1894,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct TalkSpeakParams: Codable, Sendable {
|
||||||
|
public let text: String
|
||||||
|
public let voiceid: String?
|
||||||
|
public let modelid: String?
|
||||||
|
public let outputformat: String?
|
||||||
|
public let speed: Double?
|
||||||
|
public let stability: Double?
|
||||||
|
public let similarity: Double?
|
||||||
|
public let style: Double?
|
||||||
|
public let speakerboost: Bool?
|
||||||
|
public let seed: Int?
|
||||||
|
public let normalize: String?
|
||||||
|
public let language: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
text: String,
|
||||||
|
voiceid: String?,
|
||||||
|
modelid: String?,
|
||||||
|
outputformat: String?,
|
||||||
|
speed: Double?,
|
||||||
|
stability: Double?,
|
||||||
|
similarity: Double?,
|
||||||
|
style: Double?,
|
||||||
|
speakerboost: Bool?,
|
||||||
|
seed: Int?,
|
||||||
|
normalize: String?,
|
||||||
|
language: String?)
|
||||||
|
{
|
||||||
|
self.text = text
|
||||||
|
self.voiceid = voiceid
|
||||||
|
self.modelid = modelid
|
||||||
|
self.outputformat = outputformat
|
||||||
|
self.speed = speed
|
||||||
|
self.stability = stability
|
||||||
|
self.similarity = similarity
|
||||||
|
self.style = style
|
||||||
|
self.speakerboost = speakerboost
|
||||||
|
self.seed = seed
|
||||||
|
self.normalize = normalize
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case text
|
||||||
|
case voiceid = "voiceId"
|
||||||
|
case modelid = "modelId"
|
||||||
|
case outputformat = "outputFormat"
|
||||||
|
case speed
|
||||||
|
case stability
|
||||||
|
case similarity
|
||||||
|
case style
|
||||||
|
case speakerboost = "speakerBoost"
|
||||||
|
case seed
|
||||||
|
case normalize
|
||||||
|
case language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TalkSpeakResult: Codable, Sendable {
|
||||||
|
public let audiobase64: String
|
||||||
|
public let provider: String
|
||||||
|
public let outputformat: String?
|
||||||
|
public let voicecompatible: Bool?
|
||||||
|
public let mimetype: String?
|
||||||
|
public let fileextension: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
audiobase64: String,
|
||||||
|
provider: String,
|
||||||
|
outputformat: String?,
|
||||||
|
voicecompatible: Bool?,
|
||||||
|
mimetype: String?,
|
||||||
|
fileextension: String?)
|
||||||
|
{
|
||||||
|
self.audiobase64 = audiobase64
|
||||||
|
self.provider = provider
|
||||||
|
self.outputformat = outputformat
|
||||||
|
self.voicecompatible = voicecompatible
|
||||||
|
self.mimetype = mimetype
|
||||||
|
self.fileextension = fileextension
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case audiobase64 = "audioBase64"
|
||||||
|
case provider
|
||||||
|
case outputformat = "outputFormat"
|
||||||
|
case voicecompatible = "voiceCompatible"
|
||||||
|
case mimetype = "mimeType"
|
||||||
|
case fileextension = "fileExtension"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChannelsStatusParams: Codable, Sendable {
|
public struct ChannelsStatusParams: Codable, Sendable {
|
||||||
public let probe: Bool?
|
public let probe: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|||||||
@ -45,7 +45,7 @@ import Testing
|
|||||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||||
try makeExecutableForTests(at: nodePath)
|
try makeExecutableForTests(at: nodePath)
|
||||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||||
try makeExecutableForTests(at: scriptPath)
|
try makeExecutableForTests(at: scriptPath)
|
||||||
|
|
||||||
|
|||||||
@ -240,7 +240,7 @@ struct ExecAllowlistTests {
|
|||||||
#expect(resolutions[0].executableName == "touch")
|
#expect(resolutions[0].executableName == "touch")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
|
||||||
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
command: command,
|
command: command,
|
||||||
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
|
|||||||
cwd: nil,
|
cwd: nil,
|
||||||
env: ["PATH": "/usr/bin:/bin"])
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
#expect(resolutions.count == 1)
|
#expect(resolutions.count == 1)
|
||||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||||
#expect(resolutions[0].executableName == "touch")
|
#expect(resolutions[0].executableName == "env")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||||
command: command,
|
command: command,
|
||||||
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
|
|||||||
cwd: nil,
|
cwd: nil,
|
||||||
env: ["PATH": "/usr/bin:/bin"])
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
#expect(resolutions.count == 1)
|
#expect(resolutions.count == 1)
|
||||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||||
#expect(resolutions[0].executableName == "printf")
|
#expect(resolutions[0].executableName == "env")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||||
|
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||||
|
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||||
|
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||||
|
command: command,
|
||||||
|
rawCommand: rawCommand,
|
||||||
|
cwd: nil,
|
||||||
|
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||||
|
agentId: nil)
|
||||||
|
|
||||||
|
#expect(evaluation.displayCommand == rawCommand)
|
||||||
|
#expect(evaluation.allowlistResolutions.count == 1)
|
||||||
|
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||||
|
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||||
|
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||||
|
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||||
|
cwd: nil,
|
||||||
|
env: ["PATH": "/usr/bin:/bin"])
|
||||||
|
|
||||||
|
#expect(patterns == ["/usr/bin/printf"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `match all requires every segment to match`() {
|
@Test func `match all requires every segment to match`() {
|
||||||
|
|||||||
@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
|
|||||||
try await self.withTempStateDir { _ in
|
try await self.withTempStateDir { _ in
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
let url = ExecApprovalsStore.fileURL()
|
let url = ExecApprovalsStore.fileURL()
|
||||||
let firstWriteDate = try Self.modificationDate(at: url)
|
let firstIdentity = try Self.fileIdentity(at: url)
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
let secondWriteDate = try Self.modificationDate(at: url)
|
let secondIdentity = try Self.fileIdentity(at: url)
|
||||||
|
|
||||||
#expect(firstWriteDate == secondWriteDate)
|
#expect(firstIdentity == secondIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func modificationDate(at url: URL) throws -> Date {
|
private static func fileIdentity(at url: URL) throws -> Int {
|
||||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||||
guard let date = attributes[.modificationDate] as? Date else {
|
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||||
struct MissingDateError: Error {}
|
struct MissingIdentifierError: Error {}
|
||||||
throw MissingDateError()
|
throw MissingIdentifierError()
|
||||||
}
|
}
|
||||||
return date
|
return identifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
|
|||||||
env: [:],
|
env: [:],
|
||||||
resolution: nil,
|
resolution: nil,
|
||||||
allowlistResolutions: [],
|
allowlistResolutions: [],
|
||||||
|
allowAlwaysPatterns: [],
|
||||||
allowlistMatches: [],
|
allowlistMatches: [],
|
||||||
allowlistSatisfied: allowlistSatisfied,
|
allowlistSatisfied: allowlistSatisfied,
|
||||||
allowlistMatch: nil,
|
allowlistMatch: nil,
|
||||||
|
|||||||
@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||||
|
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||||
|
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||||
|
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case let .ok(resolved):
|
||||||
|
#expect(resolved.displayCommand == rawCommand)
|
||||||
|
#expect(resolved.evaluationRawCommand == nil)
|
||||||
|
case let .invalid(message):
|
||||||
|
Issue.record("unexpected invalid result: \(message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||||
let fixtureURL = try self.findContractFixtureURL()
|
let fixtureURL = try self.findContractFixtureURL()
|
||||||
let data = try Data(contentsOf: fixtureURL)
|
let data = try Data(contentsOf: fixtureURL)
|
||||||
|
|||||||
@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
|
|||||||
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
|
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
|
||||||
#expect(env["OPENCLAW_TOKEN"] == "secret")
|
#expect(env["OPENCLAW_TOKEN"] == "secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `inspect overrides rejects blocked and invalid keys`() {
|
||||||
|
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
|
||||||
|
"CLASSPATH": "/tmp/evil-classpath",
|
||||||
|
"BAD-KEY": "x",
|
||||||
|
"ProgramFiles(x86)": "C:\\Program Files (x86)",
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
|
||||||
|
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `sanitize accepts Windows-style override key names`() {
|
||||||
|
let env = HostEnvSanitizer.sanitize(overrides: [
|
||||||
|
"ProgramFiles(x86)": "D:\\SDKs",
|
||||||
|
"CommonProgramFiles(x86)": "D:\\Common",
|
||||||
|
])
|
||||||
|
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
|
||||||
|
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
|
|||||||
#expect(response.ok == false)
|
#expect(response.ok == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let params = OpenClawSystemRunParams(
|
||||||
|
command: ["/bin/sh", "-lc", "echo ok"],
|
||||||
|
env: ["CLASSPATH": "/tmp/evil-classpath"])
|
||||||
|
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||||
|
#expect(response.error?.message.contains("CLASSPATH") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
|
||||||
|
let runtime = MacNodeRuntime()
|
||||||
|
let params = OpenClawSystemRunParams(
|
||||||
|
command: ["/bin/sh", "-lc", "echo ok"],
|
||||||
|
env: ["BAD-KEY": "x"])
|
||||||
|
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||||
|
let response = await runtime.handleInvoke(
|
||||||
|
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||||
|
#expect(response.ok == false)
|
||||||
|
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||||
|
#expect(response.error?.message.contains("BAD-KEY") == true)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func `handle invoke rejects empty system which`() async throws {
|
@Test func `handle invoke rejects empty system which`() async throws {
|
||||||
let runtime = MacNodeRuntime()
|
let runtime = MacNodeRuntime()
|
||||||
let params = OpenClawSystemWhichParams(bins: [])
|
let params = OpenClawSystemWhichParams(bins: [])
|
||||||
|
|||||||
@ -289,6 +289,17 @@ public final class OpenClawChatViewModel {
|
|||||||
stopReason: message.stopReason)
|
stopReason: message.stopReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
||||||
|
message.content.map { item in
|
||||||
|
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||||
|
}.joined(separator: "\\u{001E}")
|
||||||
|
}
|
||||||
|
|
||||||
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
guard !role.isEmpty else { return nil }
|
guard !role.isEmpty else { return nil }
|
||||||
@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
|
|||||||
return String(format: "%.3f", value)
|
return String(format: "%.3f", value)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let contentFingerprint = message.content.map { item in
|
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
||||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
|
||||||
}.joined(separator: "\\u{001E}")
|
|
||||||
|
|
||||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||||
@ -315,6 +318,19 @@ public final class OpenClawChatViewModel {
|
|||||||
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||||
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard role == "user" else { return nil }
|
||||||
|
|
||||||
|
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
||||||
|
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
private static func reconcileMessageIDs(
|
private static func reconcileMessageIDs(
|
||||||
previous: [OpenClawChatMessage],
|
previous: [OpenClawChatMessage],
|
||||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||||
@ -353,6 +369,75 @@ public final class OpenClawChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func reconcileRunRefreshMessages(
|
||||||
|
previous: [OpenClawChatMessage],
|
||||||
|
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||||
|
{
|
||||||
|
guard !previous.isEmpty else { return incoming }
|
||||||
|
guard !incoming.isEmpty else { return previous }
|
||||||
|
|
||||||
|
func countKeys(_ keys: [String]) -> [String: Int] {
|
||||||
|
keys.reduce(into: [:]) { counts, key in
|
||||||
|
counts[key, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
|
||||||
|
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
|
||||||
|
var remainingIncomingUserRefreshCounts = countKeys(
|
||||||
|
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
|
||||||
|
|
||||||
|
var lastMatchedPreviousIndex: Int?
|
||||||
|
for (index, message) in previous.enumerated() {
|
||||||
|
if let key = Self.messageIdentityKey(for: message),
|
||||||
|
incomingIdentityKeys.contains(key)
|
||||||
|
{
|
||||||
|
lastMatchedPreviousIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let userKey = Self.userRefreshIdentityKey(for: message),
|
||||||
|
let remaining = remainingIncomingUserRefreshCounts[userKey],
|
||||||
|
remaining > 0
|
||||||
|
{
|
||||||
|
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
||||||
|
lastMatchedPreviousIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trailingUserMessages = (lastMatchedPreviousIndex != nil
|
||||||
|
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
|
||||||
|
: ArraySlice(previous))
|
||||||
|
.filter { message in
|
||||||
|
guard message.role.lowercased() == "user" else { return false }
|
||||||
|
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
|
||||||
|
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
|
||||||
|
if remaining > 0 {
|
||||||
|
remainingIncomingUserRefreshCounts[key] = remaining - 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !trailingUserMessages.isEmpty else {
|
||||||
|
return reconciled
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in trailingUserMessages {
|
||||||
|
guard let messageTimestamp = message.timestamp else {
|
||||||
|
reconciled.append(message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertIndex = reconciled.firstIndex { existing in
|
||||||
|
guard let existingTimestamp = existing.timestamp else { return false }
|
||||||
|
return existingTimestamp > messageTimestamp
|
||||||
|
} ?? reconciled.endIndex
|
||||||
|
reconciled.insert(message, at: insertIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self.dedupeMessages(reconciled)
|
||||||
|
}
|
||||||
|
|
||||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||||
var result: [OpenClawChatMessage] = []
|
var result: [OpenClawChatMessage] = []
|
||||||
result.reserveCapacity(messages.count)
|
result.reserveCapacity(messages.count)
|
||||||
@ -919,7 +1004,7 @@ public final class OpenClawChatViewModel {
|
|||||||
private func refreshHistoryAfterRun() async {
|
private func refreshHistoryAfterRun() async {
|
||||||
do {
|
do {
|
||||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||||
self.messages = Self.reconcileMessageIDs(
|
self.messages = Self.reconcileRunRefreshMessages(
|
||||||
previous: self.messages,
|
previous: self.messages,
|
||||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||||
self.sessionId = payload.sessionId
|
self.sessionId = payload.sessionId
|
||||||
|
|||||||
@ -513,8 +513,11 @@ public actor GatewayChannelActor {
|
|||||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
let authToken =
|
let authToken =
|
||||||
explicitToken ??
|
explicitToken ??
|
||||||
(includeDeviceIdentity && explicitPassword == nil &&
|
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||||
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
|
// silently reusing an older stored device token.
|
||||||
|
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
||||||
|
? storedToken
|
||||||
|
: nil)
|
||||||
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||||
let authSource: GatewayAuthSource
|
let authSource: GatewayAuthSource
|
||||||
|
|||||||
@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SessionsCreateParams: Codable, Sendable {
|
||||||
|
public let key: String?
|
||||||
|
public let agentid: String?
|
||||||
|
public let label: String?
|
||||||
|
public let model: String?
|
||||||
|
public let parentsessionkey: String?
|
||||||
|
public let task: String?
|
||||||
|
public let message: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String?,
|
||||||
|
agentid: String?,
|
||||||
|
label: String?,
|
||||||
|
model: String?,
|
||||||
|
parentsessionkey: String?,
|
||||||
|
task: String?,
|
||||||
|
message: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.agentid = agentid
|
||||||
|
self.label = label
|
||||||
|
self.model = model
|
||||||
|
self.parentsessionkey = parentsessionkey
|
||||||
|
self.task = task
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case agentid = "agentId"
|
||||||
|
case label
|
||||||
|
case model
|
||||||
|
case parentsessionkey = "parentSessionKey"
|
||||||
|
case task
|
||||||
|
case message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsSendParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let message: String
|
||||||
|
public let thinking: String?
|
||||||
|
public let attachments: [AnyCodable]?
|
||||||
|
public let timeoutms: Int?
|
||||||
|
public let idempotencykey: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String,
|
||||||
|
message: String,
|
||||||
|
thinking: String?,
|
||||||
|
attachments: [AnyCodable]?,
|
||||||
|
timeoutms: Int?,
|
||||||
|
idempotencykey: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.message = message
|
||||||
|
self.thinking = thinking
|
||||||
|
self.attachments = attachments
|
||||||
|
self.timeoutms = timeoutms
|
||||||
|
self.idempotencykey = idempotencykey
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case message
|
||||||
|
case thinking
|
||||||
|
case attachments
|
||||||
|
case timeoutms = "timeoutMs"
|
||||||
|
case idempotencykey = "idempotencyKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsAbortParams: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let runid: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String,
|
||||||
|
runid: String?)
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.runid = runid
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case runid = "runId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SessionsPatchParams: Codable, Sendable {
|
public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let label: AnyCodable?
|
public let label: AnyCodable?
|
||||||
@ -1894,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct TalkSpeakParams: Codable, Sendable {
|
||||||
|
public let text: String
|
||||||
|
public let voiceid: String?
|
||||||
|
public let modelid: String?
|
||||||
|
public let outputformat: String?
|
||||||
|
public let speed: Double?
|
||||||
|
public let stability: Double?
|
||||||
|
public let similarity: Double?
|
||||||
|
public let style: Double?
|
||||||
|
public let speakerboost: Bool?
|
||||||
|
public let seed: Int?
|
||||||
|
public let normalize: String?
|
||||||
|
public let language: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
text: String,
|
||||||
|
voiceid: String?,
|
||||||
|
modelid: String?,
|
||||||
|
outputformat: String?,
|
||||||
|
speed: Double?,
|
||||||
|
stability: Double?,
|
||||||
|
similarity: Double?,
|
||||||
|
style: Double?,
|
||||||
|
speakerboost: Bool?,
|
||||||
|
seed: Int?,
|
||||||
|
normalize: String?,
|
||||||
|
language: String?)
|
||||||
|
{
|
||||||
|
self.text = text
|
||||||
|
self.voiceid = voiceid
|
||||||
|
self.modelid = modelid
|
||||||
|
self.outputformat = outputformat
|
||||||
|
self.speed = speed
|
||||||
|
self.stability = stability
|
||||||
|
self.similarity = similarity
|
||||||
|
self.style = style
|
||||||
|
self.speakerboost = speakerboost
|
||||||
|
self.seed = seed
|
||||||
|
self.normalize = normalize
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case text
|
||||||
|
case voiceid = "voiceId"
|
||||||
|
case modelid = "modelId"
|
||||||
|
case outputformat = "outputFormat"
|
||||||
|
case speed
|
||||||
|
case stability
|
||||||
|
case similarity
|
||||||
|
case style
|
||||||
|
case speakerboost = "speakerBoost"
|
||||||
|
case seed
|
||||||
|
case normalize
|
||||||
|
case language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TalkSpeakResult: Codable, Sendable {
|
||||||
|
public let audiobase64: String
|
||||||
|
public let provider: String
|
||||||
|
public let outputformat: String?
|
||||||
|
public let voicecompatible: Bool?
|
||||||
|
public let mimetype: String?
|
||||||
|
public let fileextension: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
audiobase64: String,
|
||||||
|
provider: String,
|
||||||
|
outputformat: String?,
|
||||||
|
voicecompatible: Bool?,
|
||||||
|
mimetype: String?,
|
||||||
|
fileextension: String?)
|
||||||
|
{
|
||||||
|
self.audiobase64 = audiobase64
|
||||||
|
self.provider = provider
|
||||||
|
self.outputformat = outputformat
|
||||||
|
self.voicecompatible = voicecompatible
|
||||||
|
self.mimetype = mimetype
|
||||||
|
self.fileextension = fileextension
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case audiobase64 = "audioBase64"
|
||||||
|
case provider
|
||||||
|
case outputformat = "outputFormat"
|
||||||
|
case voicecompatible = "voiceCompatible"
|
||||||
|
case mimetype = "mimeType"
|
||||||
|
case fileextension = "fileExtension"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChannelsStatusParams: Codable, Sendable {
|
public struct ChannelsStatusParams: Codable, Sendable {
|
||||||
public let probe: Bool?
|
public let probe: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|||||||
@ -126,6 +126,28 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func sendMessageAndEmitFinal(
|
||||||
|
transport: TestChatTransport,
|
||||||
|
vm: OpenClawChatViewModel,
|
||||||
|
text: String,
|
||||||
|
sessionKey: String = "main") async throws -> String
|
||||||
|
{
|
||||||
|
await sendUserMessage(vm, text: text)
|
||||||
|
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||||
|
|
||||||
|
let runId = try #require(await transport.lastSentRunId())
|
||||||
|
transport.emit(
|
||||||
|
.chat(
|
||||||
|
OpenClawChatEventPayload(
|
||||||
|
runId: runId,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
state: "final",
|
||||||
|
message: nil,
|
||||||
|
errorMessage: nil)))
|
||||||
|
return runId
|
||||||
|
}
|
||||||
|
|
||||||
private func emitAssistantText(
|
private func emitAssistantText(
|
||||||
transport: TestChatTransport,
|
transport: TestChatTransport,
|
||||||
runId: String,
|
runId: String,
|
||||||
@ -439,6 +461,141 @@ extension TestChatTransportState {
|
|||||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws {
|
||||||
|
let sessionId = "sess-main"
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
let history1 = historyPayload(sessionId: sessionId)
|
||||||
|
let history2 = historyPayload(
|
||||||
|
sessionId: sessionId,
|
||||||
|
messages: [
|
||||||
|
chatTextMessage(
|
||||||
|
role: "assistant",
|
||||||
|
text: "final answer",
|
||||||
|
timestamp: now + 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||||
|
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||||
|
try await sendMessageAndEmitFinal(
|
||||||
|
transport: transport,
|
||||||
|
vm: vm,
|
||||||
|
text: "hello from mac webchat")
|
||||||
|
|
||||||
|
try await waitUntil("assistant history refreshes without dropping user message") {
|
||||||
|
await MainActor.run {
|
||||||
|
let texts = vm.messages.map { message in
|
||||||
|
(message.role, message.content.compactMap(\.text).joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
return texts.contains(where: { $0.0 == "assistant" && $0.1 == "final answer" }) &&
|
||||||
|
texts.contains(where: { $0.0 == "user" && $0.1 == "hello from mac webchat" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws {
|
||||||
|
let sessionId = "sess-main"
|
||||||
|
let history1 = historyPayload(sessionId: sessionId)
|
||||||
|
let history2 = historyPayload(sessionId: sessionId, messages: [])
|
||||||
|
|
||||||
|
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||||
|
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||||
|
try await sendMessageAndEmitFinal(
|
||||||
|
transport: transport,
|
||||||
|
vm: vm,
|
||||||
|
text: "hello from mac webchat")
|
||||||
|
|
||||||
|
try await waitUntil("empty refresh does not clear optimistic user message") {
|
||||||
|
await MainActor.run {
|
||||||
|
vm.messages.contains { message in
|
||||||
|
message.role == "user" &&
|
||||||
|
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws {
|
||||||
|
let sessionId = "sess-main"
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
let history1 = historyPayload(sessionId: sessionId)
|
||||||
|
let history2 = historyPayload(
|
||||||
|
sessionId: sessionId,
|
||||||
|
messages: [
|
||||||
|
chatTextMessage(
|
||||||
|
role: "user",
|
||||||
|
text: "hello from mac webchat",
|
||||||
|
timestamp: now + 5_000),
|
||||||
|
chatTextMessage(
|
||||||
|
role: "assistant",
|
||||||
|
text: "final answer",
|
||||||
|
timestamp: now + 6_000),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||||
|
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||||
|
try await sendMessageAndEmitFinal(
|
||||||
|
transport: transport,
|
||||||
|
vm: vm,
|
||||||
|
text: "hello from mac webchat")
|
||||||
|
|
||||||
|
try await waitUntil("canonical refresh keeps one user message") {
|
||||||
|
await MainActor.run {
|
||||||
|
let userMessages = vm.messages.filter { message in
|
||||||
|
message.role == "user" &&
|
||||||
|
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
||||||
|
}
|
||||||
|
let hasAssistant = vm.messages.contains { message in
|
||||||
|
message.role == "assistant" &&
|
||||||
|
message.content.compactMap(\.text).joined(separator: "\n") == "final answer"
|
||||||
|
}
|
||||||
|
return hasAssistant && userMessages.count == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws {
|
||||||
|
let sessionId = "sess-main"
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
let history1 = historyPayload(sessionId: sessionId)
|
||||||
|
let history2 = historyPayload(
|
||||||
|
sessionId: sessionId,
|
||||||
|
messages: [
|
||||||
|
chatTextMessage(
|
||||||
|
role: "user",
|
||||||
|
text: "retry",
|
||||||
|
timestamp: now + 5_000),
|
||||||
|
chatTextMessage(
|
||||||
|
role: "assistant",
|
||||||
|
text: "first answer",
|
||||||
|
timestamp: now + 6_000),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2])
|
||||||
|
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||||
|
try await sendMessageAndEmitFinal(
|
||||||
|
transport: transport,
|
||||||
|
vm: vm,
|
||||||
|
text: "retry")
|
||||||
|
try await sendMessageAndEmitFinal(
|
||||||
|
transport: transport,
|
||||||
|
vm: vm,
|
||||||
|
text: "retry")
|
||||||
|
|
||||||
|
try await waitUntil("repeated optimistic user message is preserved") {
|
||||||
|
await MainActor.run {
|
||||||
|
let retryMessages = vm.messages.filter { message in
|
||||||
|
message.role == "user" &&
|
||||||
|
message.content.compactMap(\.text).joined(separator: "\n") == "retry"
|
||||||
|
}
|
||||||
|
let hasAssistant = vm.messages.contains { message in
|
||||||
|
message.role == "assistant" &&
|
||||||
|
message.content.compactMap(\.text).joined(separator: "\n") == "first answer"
|
||||||
|
}
|
||||||
|
return hasAssistant && retryMessages.count == 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||||
let history1 = historyPayload()
|
let history1 = historyPayload()
|
||||||
let history2 = historyPayload(
|
let history2 = historyPayload(
|
||||||
|
|||||||
@ -15,6 +15,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var _state: URLSessionTask.State = .suspended
|
private var _state: URLSessionTask.State = .suspended
|
||||||
private var connectRequestId: String?
|
private var connectRequestId: String?
|
||||||
|
private var connectAuth: [String: Any]?
|
||||||
private var receivePhase = 0
|
private var receivePhase = 0
|
||||||
private var pendingReceiveHandler:
|
private var pendingReceiveHandler:
|
||||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
@ -50,9 +51,17 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||||||
obj["method"] as? String == "connect",
|
obj["method"] as? String == "connect",
|
||||||
let id = obj["id"] as? String
|
let id = obj["id"] as? String
|
||||||
{
|
{
|
||||||
self.lock.withLock { self.connectRequestId = id }
|
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
|
||||||
|
self.lock.withLock {
|
||||||
|
self.connectRequestId = id
|
||||||
|
self.connectAuth = auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestConnectAuth() -> [String: Any]? {
|
||||||
|
self.lock.withLock { self.connectAuth }
|
||||||
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||||
pongReceiveHandler(nil)
|
pongReceiveHandler(nil)
|
||||||
@ -169,6 +178,62 @@ private actor SeqGapProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayNodeSessionTests {
|
struct GatewayNodeSessionTests {
|
||||||
|
@Test
|
||||||
|
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
|
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||||
|
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||||
|
defer {
|
||||||
|
if let previousStateDir {
|
||||||
|
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||||
|
} else {
|
||||||
|
unsetenv("OPENCLAW_STATE_DIR")
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
|
_ = DeviceAuthStore.storeToken(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: "operator",
|
||||||
|
token: "stored-device-token")
|
||||||
|
|
||||||
|
let session = FakeGatewayWebSocketSession()
|
||||||
|
let gateway = GatewayNodeSession()
|
||||||
|
let options = GatewayConnectOptions(
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
caps: [],
|
||||||
|
commands: [],
|
||||||
|
permissions: [:],
|
||||||
|
clientId: "openclaw-ios-test",
|
||||||
|
clientMode: "ui",
|
||||||
|
clientDisplayName: "iOS Test",
|
||||||
|
includeDeviceIdentity: true)
|
||||||
|
|
||||||
|
try await gateway.connect(
|
||||||
|
url: URL(string: "ws://example.invalid")!,
|
||||||
|
token: nil,
|
||||||
|
bootstrapToken: "fresh-bootstrap-token",
|
||||||
|
password: nil,
|
||||||
|
connectOptions: options,
|
||||||
|
sessionBox: WebSocketSessionBox(session: session),
|
||||||
|
onConnected: {},
|
||||||
|
onDisconnected: { _ in },
|
||||||
|
onInvoke: { req in
|
||||||
|
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
let auth = try #require(session.latestTask()?.latestConnectAuth())
|
||||||
|
#expect(auth["bootstrapToken"] as? String == "fresh-bootstrap-token")
|
||||||
|
#expect(auth["token"] == nil)
|
||||||
|
#expect(auth["deviceToken"] == nil)
|
||||||
|
|
||||||
|
await gateway.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||||
let normalized = canonicalizeCanvasHostUrl(
|
let normalized = canonicalizeCanvasHostUrl(
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev
|
|
||||||
@ -1 +0,0 @@
|
|||||||
- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.
|
|
||||||
@ -16,7 +16,7 @@ services:
|
|||||||
## Uncomment the lines below to enable sandbox isolation
|
## Uncomment the lines below to enable sandbox isolation
|
||||||
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
||||||
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
||||||
## docker-setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
|
## scripts/docker/setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
|
||||||
## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
|
## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
|
||||||
# - /var/run/docker.sock:/var/run/docker.sock
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
# group_add:
|
# group_add:
|
||||||
|
|||||||
612
docker-setup.sh
612
docker-setup.sh
@ -2,615 +2,11 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
SCRIPT_PATH="$ROOT_DIR/scripts/docker/setup.sh"
|
||||||
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
|
|
||||||
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
|
|
||||||
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
|
|
||||||
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
|
|
||||||
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
|
|
||||||
SANDBOX_ENABLED=""
|
|
||||||
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
|
|
||||||
TIMEZONE="${OPENCLAW_TZ:-}"
|
|
||||||
|
|
||||||
fail() {
|
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
||||||
echo "ERROR: $*" >&2
|
echo "Docker setup script not found at $SCRIPT_PATH" >&2
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
|
||||||
echo "Missing dependency: $1" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
is_truthy_value() {
|
|
||||||
local raw="${1:-}"
|
|
||||||
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
case "$raw" in
|
|
||||||
1 | true | yes | on) return 0 ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
read_config_gateway_token() {
|
|
||||||
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
|
|
||||||
if [[ ! -f "$config_path" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
|
||||||
python3 - "$config_path" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
cfg = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
gateway = cfg.get("gateway")
|
|
||||||
if not isinstance(gateway, dict):
|
|
||||||
raise SystemExit(0)
|
|
||||||
auth = gateway.get("auth")
|
|
||||||
if not isinstance(auth, dict):
|
|
||||||
raise SystemExit(0)
|
|
||||||
token = auth.get("token")
|
|
||||||
if isinstance(token, str):
|
|
||||||
token = token.strip()
|
|
||||||
if token:
|
|
||||||
print(token)
|
|
||||||
PY
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if command -v node >/dev/null 2>&1; then
|
|
||||||
node - "$config_path" <<'NODE'
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const configPath = process.argv[2];
|
|
||||||
try {
|
|
||||||
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
||||||
const token = cfg?.gateway?.auth?.token;
|
|
||||||
if (typeof token === "string" && token.trim().length > 0) {
|
|
||||||
process.stdout.write(token.trim());
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep docker-setup resilient when config parsing fails.
|
|
||||||
}
|
|
||||||
NODE
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
read_env_gateway_token() {
|
|
||||||
local env_path="$1"
|
|
||||||
local line=""
|
|
||||||
local token=""
|
|
||||||
if [[ ! -f "$env_path" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
line="${line%$'\r'}"
|
|
||||||
if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then
|
|
||||||
token="${line#OPENCLAW_GATEWAY_TOKEN=}"
|
|
||||||
fi
|
|
||||||
done <"$env_path"
|
|
||||||
if [[ -n "$token" ]]; then
|
|
||||||
printf '%s' "$token"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_control_ui_allowed_origins() {
|
|
||||||
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local allowed_origin_json
|
|
||||||
local current_allowed_origins
|
|
||||||
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
|
|
||||||
current_allowed_origins="$(
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
|
||||||
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
|
|
||||||
)"
|
|
||||||
current_allowed_origins="${current_allowed_origins//$'\r'/}"
|
|
||||||
|
|
||||||
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
|
|
||||||
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
|
||||||
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
|
|
||||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_gateway_mode_and_bind() {
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
|
||||||
config set gateway.mode local >/dev/null
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
|
||||||
config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null
|
|
||||||
echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup."
|
|
||||||
}
|
|
||||||
|
|
||||||
contains_disallowed_chars() {
|
|
||||||
local value="$1"
|
|
||||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
is_valid_timezone() {
|
|
||||||
local value="$1"
|
|
||||||
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_mount_path_value() {
|
|
||||||
local label="$1"
|
|
||||||
local value="$2"
|
|
||||||
if [[ -z "$value" ]]; then
|
|
||||||
fail "$label cannot be empty."
|
|
||||||
fi
|
|
||||||
if contains_disallowed_chars "$value"; then
|
|
||||||
fail "$label contains unsupported control characters."
|
|
||||||
fi
|
|
||||||
if [[ "$value" =~ [[:space:]] ]]; then
|
|
||||||
fail "$label cannot contain whitespace."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_named_volume() {
|
|
||||||
local value="$1"
|
|
||||||
if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
|
||||||
fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_mount_spec() {
|
|
||||||
local mount="$1"
|
|
||||||
if contains_disallowed_chars "$mount"; then
|
|
||||||
fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters."
|
|
||||||
fi
|
|
||||||
# Keep mount specs strict to avoid YAML structure injection.
|
|
||||||
# Expected format: source:target[:options]
|
|
||||||
if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then
|
|
||||||
fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd docker
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
|
||||||
echo "Docker Compose not available (try: docker compose version)" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
|
exec "$SCRIPT_PATH" "$@"
|
||||||
DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
|
|
||||||
fi
|
|
||||||
if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
|
|
||||||
DOCKER_SOCKET_PATH="/var/run/docker.sock"
|
|
||||||
fi
|
|
||||||
if is_truthy_value "$RAW_SANDBOX_SETTING"; then
|
|
||||||
SANDBOX_ENABLED="1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
|
|
||||||
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
|
|
||||||
|
|
||||||
validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR"
|
|
||||||
validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR"
|
|
||||||
if [[ -n "$HOME_VOLUME_NAME" ]]; then
|
|
||||||
if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then
|
|
||||||
validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME"
|
|
||||||
else
|
|
||||||
validate_named_volume "$HOME_VOLUME_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
|
|
||||||
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
|
|
||||||
fi
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|
||||||
validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
|
|
||||||
fi
|
|
||||||
if [[ -n "$TIMEZONE" ]]; then
|
|
||||||
if contains_disallowed_chars "$TIMEZONE"; then
|
|
||||||
fail "OPENCLAW_TZ contains unsupported control characters."
|
|
||||||
fi
|
|
||||||
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
|
|
||||||
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
|
|
||||||
fi
|
|
||||||
if ! is_valid_timezone "$TIMEZONE"; then
|
|
||||||
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$OPENCLAW_CONFIG_DIR"
|
|
||||||
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
|
|
||||||
# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows
|
|
||||||
# where the container (even as root) cannot create new host subdirectories.
|
|
||||||
mkdir -p "$OPENCLAW_CONFIG_DIR/identity"
|
|
||||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent"
|
|
||||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions"
|
|
||||||
|
|
||||||
export OPENCLAW_CONFIG_DIR
|
|
||||||
export OPENCLAW_WORKSPACE_DIR
|
|
||||||
export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
|
|
||||||
export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
|
|
||||||
export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
|
|
||||||
export OPENCLAW_IMAGE="$IMAGE_NAME"
|
|
||||||
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
|
|
||||||
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
|
|
||||||
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
|
||||||
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
|
||||||
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
|
|
||||||
export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
|
|
||||||
export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
|
|
||||||
export OPENCLAW_TZ="$TIMEZONE"
|
|
||||||
|
|
||||||
# Detect Docker socket GID for sandbox group_add.
|
|
||||||
DOCKER_GID=""
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
|
|
||||||
DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
|
|
||||||
fi
|
|
||||||
export DOCKER_GID
|
|
||||||
|
|
||||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
|
||||||
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
|
|
||||||
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
|
|
||||||
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
|
|
||||||
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
|
|
||||||
else
|
|
||||||
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)"
|
|
||||||
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
|
|
||||||
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
|
|
||||||
echo "Reusing gateway token from $ROOT_DIR/.env"
|
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
|
||||||
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
|
||||||
else
|
|
||||||
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
|
|
||||||
import secrets
|
|
||||||
print(secrets.token_hex(32))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
export OPENCLAW_GATEWAY_TOKEN
|
|
||||||
|
|
||||||
COMPOSE_FILES=("$COMPOSE_FILE")
|
|
||||||
COMPOSE_ARGS=()
|
|
||||||
|
|
||||||
write_extra_compose() {
|
|
||||||
local home_volume="$1"
|
|
||||||
shift
|
|
||||||
local mount
|
|
||||||
local gateway_home_mount
|
|
||||||
local gateway_config_mount
|
|
||||||
local gateway_workspace_mount
|
|
||||||
|
|
||||||
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
|
|
||||||
services:
|
|
||||||
openclaw-gateway:
|
|
||||||
volumes:
|
|
||||||
YAML
|
|
||||||
|
|
||||||
if [[ -n "$home_volume" ]]; then
|
|
||||||
gateway_home_mount="${home_volume}:/home/node"
|
|
||||||
gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw"
|
|
||||||
gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace"
|
|
||||||
validate_mount_spec "$gateway_home_mount"
|
|
||||||
validate_mount_spec "$gateway_config_mount"
|
|
||||||
validate_mount_spec "$gateway_workspace_mount"
|
|
||||||
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for mount in "$@"; do
|
|
||||||
validate_mount_spec "$mount"
|
|
||||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
done
|
|
||||||
|
|
||||||
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
|
|
||||||
openclaw-cli:
|
|
||||||
volumes:
|
|
||||||
YAML
|
|
||||||
|
|
||||||
if [[ -n "$home_volume" ]]; then
|
|
||||||
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for mount in "$@"; do
|
|
||||||
validate_mount_spec "$mount"
|
|
||||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
|
|
||||||
validate_named_volume "$home_volume"
|
|
||||||
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
|
|
||||||
volumes:
|
|
||||||
${home_volume}:
|
|
||||||
YAML
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
|
|
||||||
# Docker socket mount is deferred until sandbox prerequisites are verified.
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|
||||||
if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
|
|
||||||
export OPENCLAW_INSTALL_DOCKER_CLI=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
VALID_MOUNTS=()
|
|
||||||
if [[ -n "$EXTRA_MOUNTS" ]]; then
|
|
||||||
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
|
|
||||||
for mount in "${mounts[@]}"; do
|
|
||||||
mount="${mount#"${mount%%[![:space:]]*}"}"
|
|
||||||
mount="${mount%"${mount##*[![:space:]]}"}"
|
|
||||||
if [[ -n "$mount" ]]; then
|
|
||||||
VALID_MOUNTS+=("$mount")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
|
|
||||||
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
|
|
||||||
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
|
|
||||||
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
|
|
||||||
else
|
|
||||||
write_extra_compose "$HOME_VOLUME_NAME"
|
|
||||||
fi
|
|
||||||
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
|
|
||||||
fi
|
|
||||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
|
||||||
COMPOSE_ARGS+=("-f" "$compose_file")
|
|
||||||
done
|
|
||||||
# Keep a base compose arg set without sandbox overlay so rollback paths can
|
|
||||||
# force a known-safe gateway service definition (no docker.sock mount).
|
|
||||||
BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
|
|
||||||
COMPOSE_HINT="docker compose"
|
|
||||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
|
||||||
COMPOSE_HINT+=" -f ${compose_file}"
|
|
||||||
done
|
|
||||||
|
|
||||||
ENV_FILE="$ROOT_DIR/.env"
|
|
||||||
upsert_env() {
|
|
||||||
local file="$1"
|
|
||||||
shift
|
|
||||||
local -a keys=("$@")
|
|
||||||
local tmp
|
|
||||||
tmp="$(mktemp)"
|
|
||||||
# Use a delimited string instead of an associative array so the script
|
|
||||||
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
|
|
||||||
local seen=" "
|
|
||||||
|
|
||||||
if [[ -f "$file" ]]; then
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
local key="${line%%=*}"
|
|
||||||
local replaced=false
|
|
||||||
for k in "${keys[@]}"; do
|
|
||||||
if [[ "$key" == "$k" ]]; then
|
|
||||||
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
|
|
||||||
seen="$seen$k "
|
|
||||||
replaced=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "$replaced" == false ]]; then
|
|
||||||
printf '%s\n' "$line" >>"$tmp"
|
|
||||||
fi
|
|
||||||
done <"$file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for k in "${keys[@]}"; do
|
|
||||||
if [[ "$seen" != *" $k "* ]]; then
|
|
||||||
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
mv "$tmp" "$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert_env "$ENV_FILE" \
|
|
||||||
OPENCLAW_CONFIG_DIR \
|
|
||||||
OPENCLAW_WORKSPACE_DIR \
|
|
||||||
OPENCLAW_GATEWAY_PORT \
|
|
||||||
OPENCLAW_BRIDGE_PORT \
|
|
||||||
OPENCLAW_GATEWAY_BIND \
|
|
||||||
OPENCLAW_GATEWAY_TOKEN \
|
|
||||||
OPENCLAW_IMAGE \
|
|
||||||
OPENCLAW_EXTRA_MOUNTS \
|
|
||||||
OPENCLAW_HOME_VOLUME \
|
|
||||||
OPENCLAW_DOCKER_APT_PACKAGES \
|
|
||||||
OPENCLAW_EXTENSIONS \
|
|
||||||
OPENCLAW_SANDBOX \
|
|
||||||
OPENCLAW_DOCKER_SOCKET \
|
|
||||||
DOCKER_GID \
|
|
||||||
OPENCLAW_INSTALL_DOCKER_CLI \
|
|
||||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
|
|
||||||
OPENCLAW_TZ
|
|
||||||
|
|
||||||
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
|
||||||
echo "==> Building Docker image: $IMAGE_NAME"
|
|
||||||
docker build \
|
|
||||||
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
|
|
||||||
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
|
|
||||||
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
|
|
||||||
-t "$IMAGE_NAME" \
|
|
||||||
-f "$ROOT_DIR/Dockerfile" \
|
|
||||||
"$ROOT_DIR"
|
|
||||||
else
|
|
||||||
echo "==> Pulling Docker image: $IMAGE_NAME"
|
|
||||||
if ! docker pull "$IMAGE_NAME"; then
|
|
||||||
echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure bind-mounted data directories are writable by the container's `node`
|
|
||||||
# user (uid 1000). Host-created dirs inherit the host user's uid which may
|
|
||||||
# differ, causing EACCES when the container tries to mkdir/write.
|
|
||||||
# Running a brief root container to chown is the portable Docker idiom --
|
|
||||||
# it works regardless of the host uid and doesn't require host-side root.
|
|
||||||
echo ""
|
|
||||||
echo "==> Fixing data-directory permissions"
|
|
||||||
# Use -xdev to restrict chown to the config-dir mount only — without it,
|
|
||||||
# the recursive chown would cross into the workspace bind mount and rewrite
|
|
||||||
# ownership of all user project files on Linux hosts.
|
|
||||||
# After fixing the config dir, only the OpenClaw metadata subdirectory
|
|
||||||
# (.openclaw/) inside the workspace gets chowned, not the user's project files.
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \
|
|
||||||
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
|
|
||||||
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Onboarding (interactive)"
|
|
||||||
echo "Docker setup pins Gateway mode to local."
|
|
||||||
echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
|
|
||||||
echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND"
|
|
||||||
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
|
|
||||||
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
|
|
||||||
echo "Install Gateway daemon: No (managed by Docker Compose)"
|
|
||||||
echo ""
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Docker gateway defaults"
|
|
||||||
sync_gateway_mode_and_bind
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Control UI origin allowlist"
|
|
||||||
ensure_control_ui_allowed_origins
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Provider setup (optional)"
|
|
||||||
echo "WhatsApp (QR):"
|
|
||||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login"
|
|
||||||
echo "Telegram (bot token):"
|
|
||||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token <token>"
|
|
||||||
echo "Discord (bot token):"
|
|
||||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
|
|
||||||
echo "Docs: https://docs.openclaw.ai/channels"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Starting gateway"
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
|
||||||
|
|
||||||
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|
||||||
echo ""
|
|
||||||
echo "==> Sandbox setup"
|
|
||||||
|
|
||||||
# Build sandbox image if Dockerfile.sandbox exists.
|
|
||||||
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
|
|
||||||
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
|
|
||||||
docker build \
|
|
||||||
-t "openclaw-sandbox:bookworm-slim" \
|
|
||||||
-f "$ROOT_DIR/Dockerfile.sandbox" \
|
|
||||||
"$ROOT_DIR"
|
|
||||||
else
|
|
||||||
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
|
|
||||||
echo " Sandbox config will be applied but no sandbox image will be built." >&2
|
|
||||||
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Defense-in-depth: verify Docker CLI in the running image before enabling
|
|
||||||
# sandbox. This avoids claiming sandbox is enabled when the image cannot
|
|
||||||
# launch sandbox containers.
|
|
||||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
|
|
||||||
echo "WARNING: Docker CLI not found inside the container image." >&2
|
|
||||||
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
|
|
||||||
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
|
|
||||||
SANDBOX_ENABLED=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Apply sandbox config only if prerequisites are met.
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|
||||||
# Mount Docker socket via a dedicated compose overlay. This overlay is
|
|
||||||
# created only after sandbox prerequisites pass, so the socket is never
|
|
||||||
# exposed when sandbox cannot actually run.
|
|
||||||
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
|
|
||||||
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
|
|
||||||
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
|
|
||||||
services:
|
|
||||||
openclaw-gateway:
|
|
||||||
volumes:
|
|
||||||
- ${DOCKER_SOCKET_PATH}:/var/run/docker.sock
|
|
||||||
YAML
|
|
||||||
if [[ -n "${DOCKER_GID:-}" ]]; then
|
|
||||||
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID}"
|
|
||||||
YAML
|
|
||||||
fi
|
|
||||||
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
|
|
||||||
echo "==> Sandbox: added Docker socket mount"
|
|
||||||
else
|
|
||||||
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
|
|
||||||
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
|
|
||||||
SANDBOX_ENABLED=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
|
||||||
# Enable sandbox in OpenClaw config.
|
|
||||||
sandbox_config_ok=true
|
|
||||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
|
||||||
config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
|
|
||||||
echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
|
|
||||||
sandbox_config_ok=false
|
|
||||||
fi
|
|
||||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
|
||||||
config set agents.defaults.sandbox.scope "agent" >/dev/null; then
|
|
||||||
echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
|
|
||||||
sandbox_config_ok=false
|
|
||||||
fi
|
|
||||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
|
||||||
config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
|
|
||||||
echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
|
|
||||||
sandbox_config_ok=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$sandbox_config_ok" == true ]]; then
|
|
||||||
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
|
|
||||||
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
|
|
||||||
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
|
|
||||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
|
||||||
else
|
|
||||||
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
|
|
||||||
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
|
|
||||||
if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
|
||||||
config set agents.defaults.sandbox.mode "off" >/dev/null; then
|
|
||||||
echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
|
|
||||||
else
|
|
||||||
echo "Sandbox mode rolled back to off due to partial sandbox config failure."
|
|
||||||
fi
|
|
||||||
if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
|
|
||||||
rm -f "$SANDBOX_COMPOSE_FILE"
|
|
||||||
fi
|
|
||||||
# Ensure gateway service definition is reset without sandbox overlay mount.
|
|
||||||
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Keep reruns deterministic: if sandbox is not active for this run, reset
|
|
||||||
# persisted sandbox mode so future execs do not require docker.sock by stale
|
|
||||||
# config alone.
|
|
||||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
|
||||||
config set agents.defaults.sandbox.mode "off" >/dev/null; then
|
|
||||||
echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
|
|
||||||
fi
|
|
||||||
if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
|
|
||||||
rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Gateway running with host port mapping."
|
|
||||||
echo "Access from tailnet devices via the host's tailnet IP."
|
|
||||||
echo "Config: $OPENCLAW_CONFIG_DIR"
|
|
||||||
echo "Workspace: $OPENCLAW_WORKSPACE_DIR"
|
|
||||||
echo "Token: $OPENCLAW_GATEWAY_TOKEN"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
|
|
||||||
echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""
|
|
||||||
|
|||||||
@ -8347,8 +8347,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "BlueBubbles",
|
"label": "@openclaw/bluebubbles",
|
||||||
"help": "iMessage via the BlueBubbles mac app + REST API.",
|
"help": "BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -9317,8 +9317,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Discord",
|
"label": "@openclaw/discord",
|
||||||
"help": "very well supported right now.",
|
"help": "Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -15229,8 +15229,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Feishu",
|
"label": "@openclaw/feishu",
|
||||||
"help": "飞书/Lark enterprise messaging.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -17231,8 +17230,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Google Chat",
|
"label": "@openclaw/googlechat",
|
||||||
"help": "Google Workspace Chat app with HTTP webhook.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -18618,8 +18616,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "iMessage",
|
"label": "@openclaw/imessage",
|
||||||
"help": "this is still a work in progress.",
|
"help": "iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -19976,8 +19974,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "IRC",
|
"label": "@openclaw/irc",
|
||||||
"help": "classic IRC networks with DM/channel routing and pairing controls.",
|
"help": "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -21499,8 +21497,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "LINE",
|
"label": "@openclaw/line",
|
||||||
"help": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -22068,8 +22065,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Matrix",
|
"label": "@openclaw/matrix",
|
||||||
"help": "open protocol; configure a homeserver + access token.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -22101,6 +22097,34 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.ackReaction",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.ackReactionScope",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"enumValues": [
|
||||||
|
"group-mentions",
|
||||||
|
"group-all",
|
||||||
|
"direct",
|
||||||
|
"all",
|
||||||
|
"none",
|
||||||
|
"off"
|
||||||
|
],
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.actions",
|
"path": "channels.matrix.actions",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22151,6 +22175,16 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.actions.profile",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.actions.reactions",
|
"path": "channels.matrix.actions.reactions",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22161,6 +22195,35 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.actions.verification",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.allowBots",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"access",
|
||||||
|
"channels",
|
||||||
|
"network"
|
||||||
|
],
|
||||||
|
"label": "Matrix Allow Bot Messages",
|
||||||
|
"help": "Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \"mentions\" to only accept bot messages that visibly mention this bot.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.allowlistOnly",
|
"path": "channels.matrix.allowlistOnly",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22171,6 +22234,16 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.allowPrivateNetwork",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.autoJoin",
|
"path": "channels.matrix.autoJoin",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22209,6 +22282,16 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.avatarUrl",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.chunkMode",
|
"path": "channels.matrix.chunkMode",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22233,6 +22316,16 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.deviceId",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.deviceName",
|
"path": "channels.matrix.deviceName",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22390,6 +22483,19 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.groups.*.allowBots",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.groups.*.autoReply",
|
"path": "channels.matrix.groups.*.autoReply",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22651,6 +22757,20 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.reactionNotifications",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"enumValues": [
|
||||||
|
"off",
|
||||||
|
"own"
|
||||||
|
],
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.replyToMode",
|
"path": "channels.matrix.replyToMode",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22706,6 +22826,19 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.rooms.*.allowBots",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.rooms.*.autoReply",
|
"path": "channels.matrix.rooms.*.autoReply",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22859,6 +22992,30 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.startupVerification",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"enumValues": [
|
||||||
|
"off",
|
||||||
|
"if-unverified"
|
||||||
|
],
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.startupVerificationCooldownHours",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.textChunkLimit",
|
"path": "channels.matrix.textChunkLimit",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22869,6 +23026,66 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings.enabled",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings.idleHours",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings.maxAgeHours",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings.spawnAcpSessions",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.matrix.threadBindings.spawnSubagentSessions",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.matrix.threadReplies",
|
"path": "channels.matrix.threadReplies",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -22905,8 +23122,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Mattermost",
|
"label": "@openclaw/mattermost",
|
||||||
"help": "self-hosted Slack-style chat; install the plugin to enable.",
|
"help": "Mattermost channel provider configuration for bot credentials, base URL, and message trigger modes. Keep mention/trigger rules strict in high-volume team channels.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -24036,8 +24253,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Microsoft Teams",
|
"label": "@openclaw/msteams",
|
||||||
"help": "Bot Framework; enterprise support.",
|
"help": "Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -24968,8 +25185,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Nextcloud Talk",
|
"label": "@openclaw/nextcloud-talk",
|
||||||
"help": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -26189,8 +26405,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Nostr",
|
"label": "@openclaw/nostr",
|
||||||
"help": "Decentralized DMs via Nostr relays (NIP-04)",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -26418,8 +26633,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Signal",
|
"label": "@openclaw/signal",
|
||||||
"help": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").",
|
"help": "Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -27965,8 +28180,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Slack",
|
"label": "@openclaw/slack",
|
||||||
"help": "supported (Socket Mode).",
|
"help": "Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -30797,8 +31012,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Synology Chat",
|
"label": "@openclaw/synology-chat",
|
||||||
"help": "Connect your Synology NAS Chat to OpenClaw",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -30821,8 +31035,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Telegram",
|
"label": "@openclaw/telegram",
|
||||||
"help": "simplest way to get started — register a bot with @BotFather and get going.",
|
"help": "Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -34813,8 +35027,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Tlon",
|
"label": "@openclaw/tlon",
|
||||||
"help": "Decentralized messaging on Urbit",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -35252,8 +35465,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Twitch",
|
"label": "@openclaw/twitch",
|
||||||
"help": "Twitch chat integration",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -35642,8 +35854,8 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "WhatsApp",
|
"label": "@openclaw/whatsapp",
|
||||||
"help": "works with your own number; recommend a separate phone + eSIM.",
|
"help": "WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.",
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -37010,8 +37222,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Zalo",
|
"label": "@openclaw/zalo",
|
||||||
"help": "Vietnam-focused messaging platform with Bot API.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -37591,8 +37802,7 @@
|
|||||||
"channels",
|
"channels",
|
||||||
"network"
|
"network"
|
||||||
],
|
],
|
||||||
"label": "Zalo Personal",
|
"label": "@openclaw/zalouser",
|
||||||
"help": "Zalo personal account via QR code login.",
|
|
||||||
"hasChildren": true
|
"hasChildren": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -53652,6 +53862,169 @@
|
|||||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "@openclaw/tavily-plugin",
|
||||||
|
"help": "OpenClaw Tavily plugin (plugin: tavily)",
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.config",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "@openclaw/tavily-plugin Config",
|
||||||
|
"help": "Plugin-defined config payload for tavily.",
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.config.webSearch",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.config.webSearch.apiKey",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"string"
|
||||||
|
],
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": true,
|
||||||
|
"tags": [
|
||||||
|
"auth",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"label": "Tavily API Key",
|
||||||
|
"help": "Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.config.webSearch.baseUrl",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Tavily Base URL",
|
||||||
|
"help": "Tavily API base URL override.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.enabled",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Enable @openclaw/tavily-plugin",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.hooks",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Plugin Hook Policy",
|
||||||
|
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.hooks.allowPromptInjection",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"access"
|
||||||
|
],
|
||||||
|
"label": "Allow Prompt Injection Hooks",
|
||||||
|
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.subagent",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "object",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Plugin Subagent Policy",
|
||||||
|
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.subagent.allowedModels",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "array",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"access"
|
||||||
|
],
|
||||||
|
"label": "Plugin Subagent Allowed Models",
|
||||||
|
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
|
||||||
|
"hasChildren": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.subagent.allowedModels.*",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.entries.tavily.subagent.allowModelOverride",
|
||||||
|
"kind": "plugin",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"access"
|
||||||
|
],
|
||||||
|
"label": "Allow Plugin Subagent Model Override",
|
||||||
|
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "plugins.entries.telegram",
|
"path": "plugins.entries.telegram",
|
||||||
"kind": "plugin",
|
"kind": "plugin",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5518}
|
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5549}
|
||||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -730,7 +730,7 @@
|
|||||||
{"recordType":"path","path":"canvasHost.port","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Port","help":"TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.","hasChildren":false}
|
{"recordType":"path","path":"canvasHost.port","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Port","help":"TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.","hasChildren":false}
|
||||||
{"recordType":"path","path":"canvasHost.root","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Root Directory","help":"Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.","hasChildren":false}
|
{"recordType":"path","path":"canvasHost.root","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Root Directory","help":"Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Channels","help":"Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.","hasChildren":true}
|
{"recordType":"path","path":"channels","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Channels","help":"Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"BlueBubbles","help":"iMessage via the BlueBubbles mac app + REST API.","hasChildren":true}
|
{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/bluebubbles","help":"BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.bluebubbles.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.bluebubbles.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.bluebubbles.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.bluebubbles.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.bluebubbles.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.bluebubbles.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -818,7 +818,7 @@
|
|||||||
{"recordType":"path","path":"channels.bluebubbles.serverUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.bluebubbles.serverUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.bluebubbles.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.bluebubbles.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.bluebubbles.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.bluebubbles.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Discord","help":"very well supported right now.","hasChildren":true}
|
{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.discord.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.discord.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.discord.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.discord.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.discord.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.discord.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -1352,7 +1352,7 @@
|
|||||||
{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
|
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/feishu","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -1532,7 +1532,7 @@
|
|||||||
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
|
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/googlechat","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.googlechat.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.googlechat.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -1660,7 +1660,7 @@
|
|||||||
{"recordType":"path","path":"channels.googlechat.typingIndicator","kind":"channel","type":"string","required":false,"enumValues":["none","message","reaction"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.googlechat.typingIndicator","kind":"channel","type":"string","required":false,"enumValues":["none","message","reaction"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.googlechat.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.googlechat.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.googlechat.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.googlechat.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"iMessage","help":"this is still a work in progress.","hasChildren":true}
|
{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/imessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.imessage.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.imessage.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.imessage.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.imessage.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.imessage.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.imessage.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -1788,7 +1788,7 @@
|
|||||||
{"recordType":"path","path":"channels.imessage.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.imessage.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.imessage.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.imessage.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.imessage.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.imessage.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC","help":"classic IRC networks with DM/channel routing and pairing controls.","hasChildren":true}
|
{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/irc","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.irc.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.irc.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.irc.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.irc.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.irc.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.irc.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -1928,7 +1928,7 @@
|
|||||||
{"recordType":"path","path":"channels.irc.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.irc.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.irc.tls","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.irc.tls","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.irc.username","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.irc.username","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"LINE","help":"LINE Messaging API bot for Japan/Taiwan/Thailand markets.","hasChildren":true}
|
{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/line","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.line.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.line.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.line.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.line.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.line.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.line.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -1980,22 +1980,30 @@
|
|||||||
{"recordType":"path","path":"channels.line.secretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.line.secretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; configure a homeserver + access token.","hasChildren":true}
|
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/matrix","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.ackReactionScope","kind":"channel","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","none","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.actions.channelInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.actions.channelInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.actions.memberInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.actions.memberInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.actions.messages","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.actions.messages","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.actions.pins","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.actions.pins","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.actions.profile","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.actions.verification","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":["access","channels","network"],"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \"mentions\" to only accept bot messages that visibly mention this bot.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.allowlistOnly","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.allowlistOnly","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.autoJoin","kind":"channel","type":"string","required":false,"enumValues":["always","allowlist","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.autoJoin","kind":"channel","type":"string","required":false,"enumValues":["always","allowlist","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.avatarUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.deviceId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.deviceName","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.deviceName","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -2010,6 +2018,7 @@
|
|||||||
{"recordType":"path","path":"channels.matrix.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.groups.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.groups.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.groups.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -2035,11 +2044,13 @@
|
|||||||
{"recordType":"path","path":"channels.matrix.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.replyToMode","kind":"channel","type":"string","required":false,"enumValues":["off","first","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.replyToMode","kind":"channel","type":"string","required":false,"enumValues":["off","first","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.rooms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.rooms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.rooms.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -2055,10 +2066,18 @@
|
|||||||
{"recordType":"path","path":"channels.matrix.rooms.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.matrix.rooms.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.matrix.rooms.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.rooms.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.startupVerification","kind":"channel","type":"string","required":false,"enumValues":["off","if-unverified"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.startupVerificationCooldownHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.textChunkLimit","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.textChunkLimit","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings.idleHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings.maxAgeHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings.spawnAcpSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.matrix.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.threadReplies","kind":"channel","type":"string","required":false,"enumValues":["off","inbound","always"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.threadReplies","kind":"channel","type":"string","required":false,"enumValues":["off","inbound","always"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.matrix.userId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.matrix.userId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost","help":"self-hosted Slack-style chat; install the plugin to enable.","hasChildren":true}
|
{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/mattermost","help":"Mattermost channel provider configuration for bot credentials, base URL, and message trigger modes. Keep mention/trigger rules strict in high-volume team channels.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.mattermost.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.mattermost.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.mattermost.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.mattermost.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.mattermost.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.mattermost.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -2158,7 +2177,7 @@
|
|||||||
{"recordType":"path","path":"channels.mattermost.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Require Mention","help":"Require @mention in channels before responding (default: true).","hasChildren":false}
|
{"recordType":"path","path":"channels.mattermost.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Require Mention","help":"Require @mention in channels before responding (default: true).","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.mattermost.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.mattermost.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.mattermost.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.mattermost.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Microsoft Teams","help":"Bot Framework; enterprise support.","hasChildren":true}
|
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/msteams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -2246,7 +2265,7 @@
|
|||||||
{"recordType":"path","path":"channels.msteams.webhook","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.msteams.webhook","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.msteams.webhook.path","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.msteams.webhook.path","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.msteams.webhook.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.msteams.webhook.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nextcloud Talk","help":"Self-hosted chat via Nextcloud Talk webhook bots.","hasChildren":true}
|
{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nextcloud-talk","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.nextcloud-talk.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -2362,7 +2381,7 @@
|
|||||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nextcloud-talk.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nextcloud-talk.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nextcloud-talk.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nostr","help":"Decentralized DMs via Nostr relays (NIP-04)","hasChildren":true}
|
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nostr","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nostr.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.nostr.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nostr.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nostr.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nostr.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nostr.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -2383,7 +2402,7 @@
|
|||||||
{"recordType":"path","path":"channels.nostr.profile.website","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nostr.profile.website","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.nostr.relays","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.nostr.relays","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.nostr.relays.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.nostr.relays.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal","help":"signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").","hasChildren":true}
|
{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.signal.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","hasChildren":false}
|
{"recordType":"path","path":"channels.signal.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.signal.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.signal.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.signal.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.signal.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -2527,7 +2546,7 @@
|
|||||||
{"recordType":"path","path":"channels.signal.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.signal.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.signal.startupTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.signal.startupTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.signal.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.signal.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack","help":"supported (Socket Mode).","hasChildren":true}
|
{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.slack.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.slack.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.slack.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.slack.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.slack.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.slack.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -2779,9 +2798,9 @@
|
|||||||
{"recordType":"path","path":"channels.slack.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.slack.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.slack.userTokenReadOnly","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["auth","channels","network","security"],"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.","hasChildren":false}
|
{"recordType":"path","path":"channels.slack.userTokenReadOnly","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["auth","channels","network","security"],"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.slack.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/slack/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.slack.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/slack/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Synology Chat","help":"Connect your Synology NAS Chat to OpenClaw","hasChildren":true}
|
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/synology-chat","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.synology-chat.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.synology-chat.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram","help":"simplest way to get started — register a bot with @BotFather and get going.","hasChildren":true}
|
{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.telegram.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.telegram.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -3139,7 +3158,7 @@
|
|||||||
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Tlon","help":"Decentralized messaging on Urbit","hasChildren":true}
|
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/tlon","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.tlon.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.tlon.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.tlon.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.tlon.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.tlon.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.tlon.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -3182,7 +3201,7 @@
|
|||||||
{"recordType":"path","path":"channels.tlon.ship","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.tlon.ship","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.tlon.showModelSignature","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.tlon.showModelSignature","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.tlon.url","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.tlon.url","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Twitch","help":"Twitch chat integration","hasChildren":true}
|
{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/twitch","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.twitch.accessToken","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.twitch.accessToken","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.twitch.accounts","kind":"channel","type":"object","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.twitch.accounts","kind":"channel","type":"object","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.twitch.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.twitch.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -3218,7 +3237,7 @@
|
|||||||
{"recordType":"path","path":"channels.twitch.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.twitch.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.twitch.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.twitch.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.twitch.username","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.twitch.username","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp","help":"works with your own number; recommend a separate phone + eSIM.","hasChildren":true}
|
{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/whatsapp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.whatsapp.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.whatsapp.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.whatsapp.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.whatsapp.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.ackReaction","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.whatsapp.accounts.*.ackReaction","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -3346,7 +3365,7 @@
|
|||||||
{"recordType":"path","path":"channels.whatsapp.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number).","hasChildren":false}
|
{"recordType":"path","path":"channels.whatsapp.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number).","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.whatsapp.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.whatsapp.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.whatsapp.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.whatsapp.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo","help":"Vietnam-focused messaging platform with Bot API.","hasChildren":true}
|
{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalo","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalo.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalo.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalo.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalo.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalo.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalo.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -3398,7 +3417,7 @@
|
|||||||
{"recordType":"path","path":"channels.zalo.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.zalo.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.zalo.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.zalo.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.zalo.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.zalo.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo Personal","help":"Zalo personal account via QR code login.","hasChildren":true}
|
{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalouser","hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalouser.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalouser.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalouser.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalouser.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
{"recordType":"path","path":"channels.zalouser.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
{"recordType":"path","path":"channels.zalouser.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
@ -4642,6 +4661,18 @@
|
|||||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin","help":"OpenClaw Tavily plugin (plugin: tavily)","hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin Config","help":"Plugin-defined config payload for tavily.","hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Tavily API Key","help":"Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Tavily Base URL","help":"Tavily API base URL override.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tavily-plugin","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true}
|
{"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true}
|
||||||
{"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false}
|
{"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false}
|
{"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
|||||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
|
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
|
||||||
|
|
||||||
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
|
Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks).
|
||||||
|
|
||||||
Common uses:
|
Common uses:
|
||||||
|
|
||||||
@ -1046,4 +1046,4 @@ node -e "import('./path/to/handler.ts').then(console.log)"
|
|||||||
- [CLI Reference: hooks](/cli/hooks)
|
- [CLI Reference: hooks](/cli/hooks)
|
||||||
- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled)
|
- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled)
|
||||||
- [Webhook Hooks](/automation/webhook)
|
- [Webhook Hooks](/automation/webhook)
|
||||||
- [Configuration](/gateway/configuration#hooks)
|
- [Configuration](/gateway/configuration-reference#hooks)
|
||||||
|
|||||||
@ -13,7 +13,7 @@ title: "Polls"
|
|||||||
- Telegram
|
- Telegram
|
||||||
- WhatsApp (web channel)
|
- WhatsApp (web channel)
|
||||||
- Discord
|
- Discord
|
||||||
- MS Teams (Adaptive Cards)
|
- Microsoft Teams (Adaptive Cards)
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ openclaw message poll --channel discord --target channel:123456789 \
|
|||||||
openclaw message poll --channel discord --target channel:123456789 \
|
openclaw message poll --channel discord --target channel:123456789 \
|
||||||
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
||||||
|
|
||||||
# MS Teams
|
# Microsoft Teams
|
||||||
openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
|
openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
|
||||||
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
||||||
```
|
```
|
||||||
@ -71,7 +71,7 @@ Params:
|
|||||||
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
|
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
|
||||||
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
||||||
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
|
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
|
||||||
- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
|
- Microsoft Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
|
||||||
|
|
||||||
## Agent tool (Message)
|
## Agent tool (Message)
|
||||||
|
|
||||||
|
|||||||
251
docs/automation/standing-orders.md
Normal file
251
docs/automation/standing-orders.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
summary: "Define permanent operating authority for autonomous agent programs"
|
||||||
|
read_when:
|
||||||
|
- Setting up autonomous agent workflows that run without per-task prompting
|
||||||
|
- Defining what the agent can do independently vs. what needs human approval
|
||||||
|
- Structuring multi-program agents with clear boundaries and escalation rules
|
||||||
|
title: "Standing Orders"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Standing Orders
|
||||||
|
|
||||||
|
Standing orders grant your agent **permanent operating authority** for defined programs. Instead of giving individual task instructions each time, you define programs with clear scope, triggers, and escalation rules — and the agent executes autonomously within those boundaries.
|
||||||
|
|
||||||
|
This is the difference between telling your assistant "send the weekly report" every Friday vs. granting standing authority: "You own the weekly report. Compile it every Friday, send it, and only escalate if something looks wrong."
|
||||||
|
|
||||||
|
## Why Standing Orders?
|
||||||
|
|
||||||
|
**Without standing orders:**
|
||||||
|
|
||||||
|
- You must prompt the agent for every task
|
||||||
|
- The agent sits idle between requests
|
||||||
|
- Routine work gets forgotten or delayed
|
||||||
|
- You become the bottleneck
|
||||||
|
|
||||||
|
**With standing orders:**
|
||||||
|
|
||||||
|
- The agent executes autonomously within defined boundaries
|
||||||
|
- Routine work happens on schedule without prompting
|
||||||
|
- You only get involved for exceptions and approvals
|
||||||
|
- The agent fills idle time productively
|
||||||
|
|
||||||
|
## How They Work
|
||||||
|
|
||||||
|
Standing orders are defined in your [agent workspace](/concepts/agent-workspace) files. The recommended approach is to include them directly in `AGENTS.md` (which is auto-injected every session) so the agent always has them in context. For larger configurations, you can also place them in a dedicated file like `standing-orders.md` and reference it from `AGENTS.md`.
|
||||||
|
|
||||||
|
Each program specifies:
|
||||||
|
|
||||||
|
1. **Scope** — what the agent is authorized to do
|
||||||
|
2. **Triggers** — when to execute (schedule, event, or condition)
|
||||||
|
3. **Approval gates** — what requires human sign-off before acting
|
||||||
|
4. **Escalation rules** — when to stop and ask for help
|
||||||
|
|
||||||
|
The agent loads these instructions every session via the workspace bootstrap files (see [Agent Workspace](/concepts/agent-workspace) for the full list of auto-injected files) and executes against them, combined with [cron jobs](/automation/cron-jobs) for time-based enforcement.
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
Put standing orders in `AGENTS.md` to guarantee they're loaded every session. The workspace bootstrap automatically injects `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, and `MEMORY.md` — but not arbitrary files in subdirectories.
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|
## Anatomy of a Standing Order
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Program: Weekly Status Report
|
||||||
|
|
||||||
|
**Authority:** Compile data, generate report, deliver to stakeholders
|
||||||
|
**Trigger:** Every Friday at 4 PM (enforced via cron job)
|
||||||
|
**Approval gate:** None for standard reports. Flag anomalies for human review.
|
||||||
|
**Escalation:** If data source is unavailable or metrics look unusual (>2σ from norm)
|
||||||
|
|
||||||
|
### Execution Steps
|
||||||
|
|
||||||
|
1. Pull metrics from configured sources
|
||||||
|
2. Compare to prior week and targets
|
||||||
|
3. Generate report in Reports/weekly/YYYY-MM-DD.md
|
||||||
|
4. Deliver summary via configured channel
|
||||||
|
5. Log completion to Agent/Logs/
|
||||||
|
|
||||||
|
### What NOT to Do
|
||||||
|
|
||||||
|
- Do not send reports to external parties
|
||||||
|
- Do not modify source data
|
||||||
|
- Do not skip delivery if metrics look bad — report accurately
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standing Orders + Cron Jobs
|
||||||
|
|
||||||
|
Standing orders define **what** the agent is authorized to do. [Cron jobs](/automation/cron-jobs) define **when** it happens. They work together:
|
||||||
|
|
||||||
|
```
|
||||||
|
Standing Order: "You own the daily inbox triage"
|
||||||
|
↓
|
||||||
|
Cron Job (8 AM daily): "Execute inbox triage per standing orders"
|
||||||
|
↓
|
||||||
|
Agent: Reads standing orders → executes steps → reports results
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron job prompt should reference the standing order rather than duplicating it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw cron create \
|
||||||
|
--name daily-inbox-triage \
|
||||||
|
--cron "0 8 * * 1-5" \
|
||||||
|
--tz America/New_York \
|
||||||
|
--timeout-seconds 300 \
|
||||||
|
--announce \
|
||||||
|
--channel bluebubbles \
|
||||||
|
--to "+1XXXXXXXXXX" \
|
||||||
|
--message "Execute daily inbox triage per standing orders. Check mail for new alerts. Parse, categorize, and persist each item. Report summary to owner. Escalate unknowns."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Content & Social Media (Weekly Cycle)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Program: Content & Social Media
|
||||||
|
|
||||||
|
**Authority:** Draft content, schedule posts, compile engagement reports
|
||||||
|
**Approval gate:** All posts require owner review for first 30 days, then standing approval
|
||||||
|
**Trigger:** Weekly cycle (Monday review → mid-week drafts → Friday brief)
|
||||||
|
|
||||||
|
### Weekly Cycle
|
||||||
|
|
||||||
|
- **Monday:** Review platform metrics and audience engagement
|
||||||
|
- **Tuesday–Thursday:** Draft social posts, create blog content
|
||||||
|
- **Friday:** Compile weekly marketing brief → deliver to owner
|
||||||
|
|
||||||
|
### Content Rules
|
||||||
|
|
||||||
|
- Voice must match the brand (see SOUL.md or brand voice guide)
|
||||||
|
- Never identify as AI in public-facing content
|
||||||
|
- Include metrics when available
|
||||||
|
- Focus on value to audience, not self-promotion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Finance Operations (Event-Triggered)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Program: Financial Processing
|
||||||
|
|
||||||
|
**Authority:** Process transaction data, generate reports, send summaries
|
||||||
|
**Approval gate:** None for analysis. Recommendations require owner approval.
|
||||||
|
**Trigger:** New data file detected OR scheduled monthly cycle
|
||||||
|
|
||||||
|
### When New Data Arrives
|
||||||
|
|
||||||
|
1. Detect new file in designated input directory
|
||||||
|
2. Parse and categorize all transactions
|
||||||
|
3. Compare against budget targets
|
||||||
|
4. Flag: unusual items, threshold breaches, new recurring charges
|
||||||
|
5. Generate report in designated output directory
|
||||||
|
6. Deliver summary to owner via configured channel
|
||||||
|
|
||||||
|
### Escalation Rules
|
||||||
|
|
||||||
|
- Single item > $500: immediate alert
|
||||||
|
- Category > budget by 20%: flag in report
|
||||||
|
- Unrecognizable transaction: ask owner for categorization
|
||||||
|
- Failed processing after 2 retries: report failure, do not guess
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Monitoring & Alerts (Continuous)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Program: System Monitoring
|
||||||
|
|
||||||
|
**Authority:** Check system health, restart services, send alerts
|
||||||
|
**Approval gate:** Restart services automatically. Escalate if restart fails twice.
|
||||||
|
**Trigger:** Every heartbeat cycle
|
||||||
|
|
||||||
|
### Checks
|
||||||
|
|
||||||
|
- Service health endpoints responding
|
||||||
|
- Disk space above threshold
|
||||||
|
- Pending tasks not stale (>24 hours)
|
||||||
|
- Delivery channels operational
|
||||||
|
|
||||||
|
### Response Matrix
|
||||||
|
|
||||||
|
| Condition | Action | Escalate? |
|
||||||
|
| ---------------- | ------------------------ | ------------------------ |
|
||||||
|
| Service down | Restart automatically | Only if restart fails 2x |
|
||||||
|
| Disk space < 10% | Alert owner | Yes |
|
||||||
|
| Stale task > 24h | Remind owner | No |
|
||||||
|
| Channel offline | Log and retry next cycle | If offline > 2 hours |
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Execute-Verify-Report Pattern
|
||||||
|
|
||||||
|
Standing orders work best when combined with strict execution discipline. Every task in a standing order should follow this loop:
|
||||||
|
|
||||||
|
1. **Execute** — Do the actual work (don't just acknowledge the instruction)
|
||||||
|
2. **Verify** — Confirm the result is correct (file exists, message delivered, data parsed)
|
||||||
|
3. **Report** — Tell the owner what was done and what was verified
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Execution Rules
|
||||||
|
|
||||||
|
- Every task follows Execute-Verify-Report. No exceptions.
|
||||||
|
- "I'll do that" is not execution. Do it, then report.
|
||||||
|
- "Done" without verification is not acceptable. Prove it.
|
||||||
|
- If execution fails: retry once with adjusted approach.
|
||||||
|
- If still fails: report failure with diagnosis. Never silently fail.
|
||||||
|
- Never retry indefinitely — 3 attempts max, then escalate.
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern prevents the most common agent failure mode: acknowledging a task without completing it.
|
||||||
|
|
||||||
|
## Multi-Program Architecture
|
||||||
|
|
||||||
|
For agents managing multiple concerns, organize standing orders as separate programs with clear boundaries:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Standing Orders
|
||||||
|
|
||||||
|
## Program 1: [Domain A] (Weekly)
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
## Program 2: [Domain B] (Monthly + On-Demand)
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
## Program 3: [Domain C] (As-Needed)
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
## Escalation Rules (All Programs)
|
||||||
|
|
||||||
|
- [Common escalation criteria]
|
||||||
|
- [Approval gates that apply across programs]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each program should have:
|
||||||
|
|
||||||
|
- Its own **trigger cadence** (weekly, monthly, event-driven, continuous)
|
||||||
|
- Its own **approval gates** (some programs need more oversight than others)
|
||||||
|
- Clear **boundaries** (the agent should know where one program ends and another begins)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- Start with narrow authority and expand as trust builds
|
||||||
|
- Define explicit approval gates for high-risk actions
|
||||||
|
- Include "What NOT to do" sections — boundaries matter as much as permissions
|
||||||
|
- Combine with cron jobs for reliable time-based execution
|
||||||
|
- Review agent logs weekly to verify standing orders are being followed
|
||||||
|
- Update standing orders as your needs evolve — they're living documents
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
|
||||||
|
- Grant broad authority on day one ("do whatever you think is best")
|
||||||
|
- Skip escalation rules — every program needs a "when to stop and ask" clause
|
||||||
|
- Assume the agent will remember verbal instructions — put everything in the file
|
||||||
|
- Mix concerns in a single program — separate programs for separate domains
|
||||||
|
- Forget to enforce with cron jobs — standing orders without triggers become suggestions
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Cron Jobs](/automation/cron-jobs) — Schedule enforcement for standing orders
|
||||||
|
- [Agent Workspace](/concepts/agent-workspace) — Where standing orders live, including the full list of auto-injected bootstrap files (AGENTS.md, SOUL.md, etc.)
|
||||||
@ -85,7 +85,7 @@ Payload:
|
|||||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
|
||||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||||
|
|||||||
@ -116,7 +116,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w
|
|||||||
|
|
||||||
Related:
|
Related:
|
||||||
|
|
||||||
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
|
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox)
|
||||||
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
|
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
|
||||||
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)
|
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)
|
||||||
|
|
||||||
@ -290,7 +290,7 @@ Example (Telegram):
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
|
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
|
||||||
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
|
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, Microsoft Teams `teams.*.channels.*`).
|
||||||
|
|
||||||
## Group allowlists
|
## Group allowlists
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: IRC
|
title: IRC
|
||||||
description: Connect OpenClaw to IRC channels and direct messages.
|
|
||||||
summary: "IRC plugin setup, access controls, and troubleshooting"
|
summary: "IRC plugin setup, access controls, and troubleshooting"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to connect OpenClaw to IRC channels or DMs
|
- You want to connect OpenClaw to IRC channels or DMs
|
||||||
- You are configuring IRC allowlists, group policy, or mention gating
|
- You are configuring IRC allowlists, group policy, or mention gating
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# IRC
|
||||||
|
|
||||||
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
|
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
|
||||||
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.
|
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.
|
||||||
|
|
||||||
@ -15,18 +16,18 @@ IRC ships as an extension plugin, but it is configured in the main config under
|
|||||||
1. Enable IRC config in `~/.openclaw/openclaw.json`.
|
1. Enable IRC config in `~/.openclaw/openclaw.json`.
|
||||||
2. Set at least:
|
2. Set at least:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"irc": {
|
irc: {
|
||||||
"enabled": true,
|
enabled: true,
|
||||||
"host": "irc.libera.chat",
|
host: "irc.libera.chat",
|
||||||
"port": 6697,
|
port: 6697,
|
||||||
"tls": true,
|
tls: true,
|
||||||
"nick": "openclaw-bot",
|
nick: "openclaw-bot",
|
||||||
"channels": ["#openclaw"]
|
channels: ["#openclaw"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ If you see logs like:
|
|||||||
|
|
||||||
Example (allow anyone in `#tuirc-dev` to talk to the bot):
|
Example (allow anyone in `#tuirc-dev` to talk to the bot):
|
||||||
|
|
||||||
```json5
|
```json55
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
irc: {
|
irc: {
|
||||||
@ -94,7 +95,7 @@ That means you may see logs like `drop channel … (missing-mention)` unless the
|
|||||||
|
|
||||||
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
|
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
|
||||||
|
|
||||||
```json5
|
```json55
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
irc: {
|
irc: {
|
||||||
@ -112,7 +113,7 @@ To make the bot reply in an IRC channel **without needing a mention**, disable m
|
|||||||
|
|
||||||
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
|
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
|
||||||
|
|
||||||
```json5
|
```json55
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
irc: {
|
irc: {
|
||||||
@ -132,7 +133,7 @@ To reduce risk, restrict tools for that channel.
|
|||||||
|
|
||||||
### Same tools for everyone in the channel
|
### Same tools for everyone in the channel
|
||||||
|
|
||||||
```json5
|
```json55
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
irc: {
|
irc: {
|
||||||
@ -153,7 +154,7 @@ To reduce risk, restrict tools for that channel.
|
|||||||
|
|
||||||
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
|
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
|
||||||
|
|
||||||
```json5
|
```json55
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
irc: {
|
irc: {
|
||||||
@ -188,32 +189,32 @@ For more on group access vs mention-gating (and how they interact), see: [/chann
|
|||||||
|
|
||||||
To identify with NickServ after connect:
|
To identify with NickServ after connect:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"irc": {
|
irc: {
|
||||||
"nickserv": {
|
nickserv: {
|
||||||
"enabled": true,
|
enabled: true,
|
||||||
"service": "NickServ",
|
service: "NickServ",
|
||||||
"password": "your-nickserv-password"
|
password: "your-nickserv-password",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional one-time registration on connect:
|
Optional one-time registration on connect:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"irc": {
|
irc: {
|
||||||
"nickserv": {
|
nickserv: {
|
||||||
"register": true,
|
register: true,
|
||||||
"registerEmail": "bot@example.com"
|
registerEmail: "bot@example.com",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or
|
|||||||
Security note:
|
Security note:
|
||||||
|
|
||||||
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
|
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
|
||||||
|
- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety.
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
|
|||||||
@ -1,83 +1,70 @@
|
|||||||
---
|
---
|
||||||
summary: "Matrix support status, capabilities, and configuration"
|
summary: "Matrix support status, setup, and configuration examples"
|
||||||
read_when:
|
read_when:
|
||||||
- Working on Matrix channel features
|
- Setting up Matrix in OpenClaw
|
||||||
|
- Configuring Matrix E2EE and verification
|
||||||
title: "Matrix"
|
title: "Matrix"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Matrix (plugin)
|
# Matrix (plugin)
|
||||||
|
|
||||||
Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user**
|
Matrix is the Matrix channel plugin for OpenClaw.
|
||||||
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
|
It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE.
|
||||||
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
|
|
||||||
but it requires E2EE to be enabled.
|
|
||||||
|
|
||||||
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
|
|
||||||
polls (send + poll-start as text), location, and E2EE (with crypto support).
|
|
||||||
|
|
||||||
## Plugin required
|
## Plugin required
|
||||||
|
|
||||||
Matrix ships as a plugin and is not bundled with the core install.
|
Matrix is a plugin and is not bundled with core OpenClaw.
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
Install from npm:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw plugins install @openclaw/matrix
|
openclaw plugins install @openclaw/matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
Local checkout (when running from a git repo):
|
Install from a local checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw plugins install ./extensions/matrix
|
openclaw plugins install ./extensions/matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
If you choose Matrix during setup and a git checkout is detected,
|
See [Plugins](/tools/plugin) for plugin behavior and install rules.
|
||||||
OpenClaw will offer the local install path automatically.
|
|
||||||
|
|
||||||
Details: [Plugins](/tools/plugin)
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Install the Matrix plugin:
|
1. Install the plugin.
|
||||||
- From npm: `openclaw plugins install @openclaw/matrix`
|
2. Create a Matrix account on your homeserver.
|
||||||
- From a local checkout: `openclaw plugins install ./extensions/matrix`
|
3. Configure `channels.matrix` with either:
|
||||||
2. Create a Matrix account on a homeserver:
|
- `homeserver` + `accessToken`, or
|
||||||
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
|
- `homeserver` + `userId` + `password`.
|
||||||
- Or host it yourself.
|
4. Restart the gateway.
|
||||||
3. Get an access token for the bot account:
|
5. Start a DM with the bot or invite it to a room.
|
||||||
- Use the Matrix login API with `curl` at your home server:
|
|
||||||
|
Interactive setup paths:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --request POST \
|
openclaw channels add
|
||||||
--url https://matrix.example.org/_matrix/client/v3/login \
|
openclaw configure --section channels
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data '{
|
|
||||||
"type": "m.login.password",
|
|
||||||
"identifier": {
|
|
||||||
"type": "m.id.user",
|
|
||||||
"user": "your-user-name"
|
|
||||||
},
|
|
||||||
"password": "your-password"
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `matrix.example.org` with your homeserver URL.
|
What the Matrix wizard actually asks for:
|
||||||
- Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same
|
|
||||||
login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`,
|
|
||||||
and reuses it on next start.
|
|
||||||
|
|
||||||
4. Configure credentials:
|
- homeserver URL
|
||||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
|
- auth method: access token or password
|
||||||
- Or config: `channels.matrix.*`
|
- user ID only when you choose password auth
|
||||||
- If both are set, config takes precedence.
|
- optional device name
|
||||||
- With access token: user ID is fetched automatically via `/whoami`.
|
- whether to enable E2EE
|
||||||
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
|
- whether to configure Matrix room access now
|
||||||
5. Restart the gateway (or finish setup).
|
|
||||||
6. Start a DM with the bot or invite it to a room from any Matrix client
|
|
||||||
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
|
|
||||||
so set `channels.matrix.encryption: true` and verify the device.
|
|
||||||
|
|
||||||
Minimal config (access token, user ID auto-fetched):
|
Wizard behavior that matters:
|
||||||
|
|
||||||
|
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
|
||||||
|
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
|
||||||
|
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
|
||||||
|
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
|
||||||
|
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
|
||||||
|
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||||
|
|
||||||
|
Minimal token-based setup:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@ -85,14 +72,14 @@ Minimal config (access token, user ID auto-fetched):
|
|||||||
matrix: {
|
matrix: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
homeserver: "https://matrix.example.org",
|
homeserver: "https://matrix.example.org",
|
||||||
accessToken: "syt_***",
|
accessToken: "syt_xxx",
|
||||||
dm: { policy: "pairing" },
|
dm: { policy: "pairing" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
E2EE config (end to end encryption enabled):
|
Password-based setup (token is cached after login):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@ -100,204 +87,591 @@ E2EE config (end to end encryption enabled):
|
|||||||
matrix: {
|
matrix: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
homeserver: "https://matrix.example.org",
|
homeserver: "https://matrix.example.org",
|
||||||
accessToken: "syt_***",
|
userId: "@bot:example.org",
|
||||||
|
password: "replace-me", // pragma: allowlist secret
|
||||||
|
deviceName: "OpenClaw Gateway",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Matrix stores cached credentials in `~/.openclaw/credentials/matrix/`.
|
||||||
|
The default account uses `credentials.json`; named accounts use `credentials-<account>.json`.
|
||||||
|
|
||||||
|
Environment variable equivalents (used when the config key is not set):
|
||||||
|
|
||||||
|
- `MATRIX_HOMESERVER`
|
||||||
|
- `MATRIX_ACCESS_TOKEN`
|
||||||
|
- `MATRIX_USER_ID`
|
||||||
|
- `MATRIX_PASSWORD`
|
||||||
|
- `MATRIX_DEVICE_ID`
|
||||||
|
- `MATRIX_DEVICE_NAME`
|
||||||
|
|
||||||
|
For non-default accounts, use account-scoped env vars:
|
||||||
|
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_HOMESERVER`
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN`
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_USER_ID`
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_PASSWORD`
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_DEVICE_ID`
|
||||||
|
- `MATRIX_<ACCOUNT_ID>_DEVICE_NAME`
|
||||||
|
|
||||||
|
Example for account `ops`:
|
||||||
|
|
||||||
|
- `MATRIX_OPS_HOMESERVER`
|
||||||
|
- `MATRIX_OPS_ACCESS_TOKEN`
|
||||||
|
|
||||||
|
For normalized account ID `ops-bot`, use:
|
||||||
|
|
||||||
|
- `MATRIX_OPS_BOT_HOMESERVER`
|
||||||
|
- `MATRIX_OPS_BOT_ACCESS_TOKEN`
|
||||||
|
|
||||||
|
The interactive wizard only offers the env-var shortcut when those auth env vars are already present and the selected account does not already have Matrix auth saved in config.
|
||||||
|
|
||||||
|
## Configuration example
|
||||||
|
|
||||||
|
This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "syt_xxx",
|
||||||
encryption: true,
|
encryption: true,
|
||||||
dm: { policy: "pairing" },
|
|
||||||
|
dm: {
|
||||||
|
policy: "pairing",
|
||||||
},
|
},
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Encryption (E2EE)
|
|
||||||
|
|
||||||
End-to-end encryption is **supported** via the Rust crypto SDK.
|
|
||||||
|
|
||||||
Enable with `channels.matrix.encryption: true`:
|
|
||||||
|
|
||||||
- If the crypto module loads, encrypted rooms are decrypted automatically.
|
|
||||||
- Outbound media is encrypted when sending to encrypted rooms.
|
|
||||||
- On first connection, OpenClaw requests device verification from your other sessions.
|
|
||||||
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
|
|
||||||
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
|
||||||
OpenClaw logs a warning.
|
|
||||||
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
|
|
||||||
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
|
|
||||||
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
|
|
||||||
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
|
|
||||||
|
|
||||||
Crypto state is stored per account + access token in
|
|
||||||
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
|
|
||||||
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
|
|
||||||
If the access token (device) changes, a new store is created and the bot must be
|
|
||||||
re-verified for encrypted rooms.
|
|
||||||
|
|
||||||
**Device verification:**
|
|
||||||
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
|
||||||
Open Element (or another client) and approve the verification request to establish trust.
|
|
||||||
Once verified, the bot can decrypt messages in encrypted rooms.
|
|
||||||
|
|
||||||
## Multi-account
|
|
||||||
|
|
||||||
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
|
||||||
|
|
||||||
Each account runs as a separate Matrix user on any homeserver. Per-account config
|
|
||||||
inherits from the top-level `channels.matrix` settings and can override any option
|
|
||||||
(DM policy, groups, encryption, etc.).
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
matrix: {
|
|
||||||
enabled: true,
|
|
||||||
dm: { policy: "pairing" },
|
|
||||||
accounts: {
|
|
||||||
assistant: {
|
|
||||||
name: "Main assistant",
|
|
||||||
homeserver: "https://matrix.example.org",
|
|
||||||
accessToken: "syt_assistant_***",
|
|
||||||
encryption: true,
|
|
||||||
},
|
|
||||||
alerts: {
|
|
||||||
name: "Alerts bot",
|
|
||||||
homeserver: "https://matrix.example.org",
|
|
||||||
accessToken: "syt_alerts_***",
|
|
||||||
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Account startup is serialized to avoid race conditions with concurrent module imports.
|
|
||||||
- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
|
|
||||||
- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
|
|
||||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
|
||||||
- Crypto state is stored per account + access token (separate key stores per account).
|
|
||||||
|
|
||||||
## Routing model
|
|
||||||
|
|
||||||
- Replies always go back to Matrix.
|
|
||||||
- DMs share the agent's main session; rooms map to group sessions.
|
|
||||||
|
|
||||||
## Access control (DMs)
|
|
||||||
|
|
||||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
|
||||||
- Approve via:
|
|
||||||
- `openclaw pairing list matrix`
|
|
||||||
- `openclaw pairing approve matrix <CODE>`
|
|
||||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
|
||||||
- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match.
|
|
||||||
- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs.
|
|
||||||
|
|
||||||
## Rooms (groups)
|
|
||||||
|
|
||||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
|
||||||
- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set).
|
|
||||||
- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
matrix: {
|
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
|
groupAllowFrom: ["@admin:example.org"],
|
||||||
groups: {
|
groups: {
|
||||||
"!roomId:example.org": { allow: true },
|
"!roomid:example.org": {
|
||||||
"#alias:example.org": { allow: true },
|
requireMention: true,
|
||||||
},
|
},
|
||||||
groupAllowFrom: ["@owner:example.org"],
|
},
|
||||||
|
|
||||||
|
autoJoin: "allowlist",
|
||||||
|
autoJoinAllowlist: ["!roomid:example.org"],
|
||||||
|
threadReplies: "inbound",
|
||||||
|
replyToMode: "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `requireMention: false` enables auto-reply in that room.
|
## E2EE setup
|
||||||
- `groups."*"` can set defaults for mention gating across rooms.
|
|
||||||
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs).
|
## Bot to bot rooms
|
||||||
- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs).
|
|
||||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match.
|
By default, Matrix messages from other configured OpenClaw Matrix accounts are ignored.
|
||||||
- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching.
|
|
||||||
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
|
Use `allowBots` when you intentionally want inter-agent Matrix traffic:
|
||||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
|
||||||
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
allowBots: "mentions", // true | "mentions"
|
||||||
|
groups: {
|
||||||
|
"!roomid:example.org": {
|
||||||
|
requireMention: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `allowBots: true` accepts messages from other configured Matrix bot accounts in allowed rooms and DMs.
|
||||||
|
- `allowBots: "mentions"` accepts those messages only when they visibly mention this bot in rooms. DMs are still allowed.
|
||||||
|
- `groups.<room>.allowBots` overrides the account-level setting for one room.
|
||||||
|
- OpenClaw still ignores messages from the same Matrix user ID to avoid self-reply loops.
|
||||||
|
- Matrix does not expose a native bot flag here; OpenClaw treats "bot-authored" as "sent by another configured Matrix account on this OpenClaw gateway".
|
||||||
|
|
||||||
|
Use strict room allowlists and mention requirements when enabling bot-to-bot traffic in shared rooms.
|
||||||
|
|
||||||
|
Enable encryption:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "syt_xxx",
|
||||||
|
encryption: true,
|
||||||
|
dm: { policy: "pairing" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check verification status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify status
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose status (full diagnostics):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify status --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Include the stored recovery key in machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify status --include-recovery-key --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap cross-signing and verification state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [Configuration reference](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern.
|
||||||
|
|
||||||
|
Verbose bootstrap diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify bootstrap --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Force a fresh cross-signing identity reset before bootstrapping:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify bootstrap --force-reset-cross-signing
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify this device with a recovery key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify device "<your-recovery-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose device verification details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify device "<your-recovery-key>" --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Check room-key backup health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup status
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose backup health diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup status --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore room keys from server backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose restore diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup restore --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the current server backup and create a fresh backup baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
|
||||||
|
Use `--json` for full machine-readable output when scripting.
|
||||||
|
|
||||||
|
In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account <id>`.
|
||||||
|
If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly.
|
||||||
|
Use `--account` whenever you want verification or device operations to target a named account explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify status --account assistant
|
||||||
|
openclaw matrix verify backup restore --account assistant
|
||||||
|
openclaw matrix devices list --account assistant
|
||||||
|
```
|
||||||
|
|
||||||
|
When encryption is disabled or unavailable for a named account, Matrix warnings and verification errors point at that account's config key, for example `channels.matrix.accounts.assistant.encryption`.
|
||||||
|
|
||||||
|
### What "verified" means
|
||||||
|
|
||||||
|
OpenClaw treats this Matrix device as verified only when it is verified by your own cross-signing identity.
|
||||||
|
In practice, `openclaw matrix verify status --verbose` exposes three trust signals:
|
||||||
|
|
||||||
|
- `Locally trusted`: this device is trusted by the current client only
|
||||||
|
- `Cross-signing verified`: the SDK reports the device as verified through cross-signing
|
||||||
|
- `Signed by owner`: the device is signed by your own self-signing key
|
||||||
|
|
||||||
|
`Verified by owner` becomes `yes` only when cross-signing verification or owner-signing is present.
|
||||||
|
Local trust by itself is not enough for OpenClaw to treat the device as fully verified.
|
||||||
|
|
||||||
|
### What bootstrap does
|
||||||
|
|
||||||
|
`openclaw matrix verify bootstrap` is the repair and setup command for encrypted Matrix accounts.
|
||||||
|
It does all of the following in order:
|
||||||
|
|
||||||
|
- bootstraps secret storage, reusing an existing recovery key when possible
|
||||||
|
- bootstraps cross-signing and uploads missing public cross-signing keys
|
||||||
|
- attempts to mark and cross-sign the current device
|
||||||
|
- creates a new server-side room-key backup if one does not already exist
|
||||||
|
|
||||||
|
If the homeserver requires interactive auth to upload cross-signing keys, OpenClaw tries the upload without auth first, then with `m.login.dummy`, then with `m.login.password` when `channels.matrix.password` is configured.
|
||||||
|
|
||||||
|
Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one.
|
||||||
|
|
||||||
|
If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||||
|
Do this only when you accept that unrecoverable old encrypted history will stay unavailable.
|
||||||
|
|
||||||
|
### Fresh backup baseline
|
||||||
|
|
||||||
|
If you want to keep future encrypted messages working and accept losing unrecoverable old history, run these commands in order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix verify backup reset --yes
|
||||||
|
openclaw matrix verify backup status --verbose
|
||||||
|
openclaw matrix verify status
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--account <id>` to each command when you want to target a named Matrix account explicitly.
|
||||||
|
|
||||||
|
### Startup behavior
|
||||||
|
|
||||||
|
When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`.
|
||||||
|
On startup, if this device is still unverified, Matrix will request self-verification in another Matrix client,
|
||||||
|
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
|
||||||
|
Failed request attempts retry sooner than successful request creation by default.
|
||||||
|
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
|
||||||
|
if you want a shorter or longer retry window.
|
||||||
|
|
||||||
|
Startup also performs a conservative crypto bootstrap pass automatically.
|
||||||
|
That pass tries to reuse the current secret storage and cross-signing identity first, and avoids resetting cross-signing unless you run an explicit bootstrap repair flow.
|
||||||
|
|
||||||
|
If startup finds broken bootstrap state and `channels.matrix.password` is configured, OpenClaw can attempt a stricter repair path.
|
||||||
|
If the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically.
|
||||||
|
|
||||||
|
Upgrading from the previous public Matrix plugin:
|
||||||
|
|
||||||
|
- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible.
|
||||||
|
- Before any actionable Matrix migration changes run, OpenClaw creates or reuses a recovery snapshot under `~/Backups/openclaw-migrations/`.
|
||||||
|
- If you use multiple Matrix accounts, set `channels.matrix.defaultAccount` before upgrading from the old flat-store layout so OpenClaw knows which account should receive that shared legacy state.
|
||||||
|
- If the previous plugin stored a Matrix room-key backup decryption key locally, startup or `openclaw doctor --fix` will import it into the new recovery-key flow automatically.
|
||||||
|
- If the Matrix access token changed after migration was prepared, startup now scans sibling token-hash storage roots for pending legacy restore state before giving up on the automatic backup restore.
|
||||||
|
- If the Matrix access token changes later for the same account, homeserver, and user, OpenClaw now prefers reusing the most complete existing token-hash storage root instead of starting from an empty Matrix state directory.
|
||||||
|
- On the next gateway start, backed-up room keys are restored automatically into the new crypto store.
|
||||||
|
- If the old plugin had local-only room keys that were never backed up, OpenClaw will warn clearly. Those keys cannot be exported automatically from the previous rust crypto store, so some old encrypted history may remain unavailable until recovered manually.
|
||||||
|
- See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.
|
||||||
|
|
||||||
|
Encrypted runtime state is organized under per-account, per-user token-hash roots in
|
||||||
|
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
|
||||||
|
That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`),
|
||||||
|
recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`),
|
||||||
|
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
|
||||||
|
when those features are in use.
|
||||||
|
When the token changes but the account identity stays the same, OpenClaw reuses the best existing
|
||||||
|
root for that account/homeserver/user tuple so prior sync state, crypto state, thread bindings,
|
||||||
|
and startup verification state remain visible.
|
||||||
|
|
||||||
|
### Node crypto store model
|
||||||
|
|
||||||
|
Matrix E2EE in this plugin uses the official `matrix-js-sdk` Rust crypto path in Node.
|
||||||
|
That path expects IndexedDB-backed persistence when you want crypto state to survive restarts.
|
||||||
|
|
||||||
|
OpenClaw currently provides that in Node by:
|
||||||
|
|
||||||
|
- using `fake-indexeddb` as the IndexedDB API shim expected by the SDK
|
||||||
|
- restoring the Rust crypto IndexedDB contents from `crypto-idb-snapshot.json` before `initRustCrypto`
|
||||||
|
- persisting the updated IndexedDB contents back to `crypto-idb-snapshot.json` after init and during runtime
|
||||||
|
|
||||||
|
This is compatibility/storage plumbing, not a custom crypto implementation.
|
||||||
|
The snapshot file is sensitive runtime state and is stored with restrictive file permissions.
|
||||||
|
Under OpenClaw's security model, the gateway host and local OpenClaw state directory are already inside the trusted operator boundary, so this is primarily an operational durability concern rather than a separate remote trust boundary.
|
||||||
|
|
||||||
|
Planned improvement:
|
||||||
|
|
||||||
|
- add SecretRef support for persistent Matrix key material so recovery keys and related store-encryption secrets can be sourced from OpenClaw secrets providers instead of only local files
|
||||||
|
|
||||||
|
## Automatic verification notices
|
||||||
|
|
||||||
|
Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages.
|
||||||
|
That includes:
|
||||||
|
|
||||||
|
- verification request notices
|
||||||
|
- verification ready notices (with explicit "Verify by emoji" guidance)
|
||||||
|
- verification start and completion notices
|
||||||
|
- SAS details (emoji and decimal) when available
|
||||||
|
|
||||||
|
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
|
||||||
|
For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side.
|
||||||
|
For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally.
|
||||||
|
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
|
||||||
|
|
||||||
|
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.
|
||||||
|
|
||||||
|
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
||||||
|
|
||||||
|
### Device hygiene
|
||||||
|
|
||||||
|
Old OpenClaw-managed Matrix devices can accumulate on the account and make encrypted-room trust harder to reason about.
|
||||||
|
List them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix devices list
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove stale OpenClaw-managed devices with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix devices prune-stale
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Room Repair
|
||||||
|
|
||||||
|
If direct-message state gets out of sync, OpenClaw can end up with stale `m.direct` mappings that point at old solo rooms instead of the live DM. Inspect the current mapping for a peer with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix direct inspect --user-id @alice:example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Repair it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw matrix direct repair --user-id @alice:example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Repair keeps the Matrix-specific logic inside the plugin:
|
||||||
|
|
||||||
|
- it prefers a strict 1:1 DM that is already mapped in `m.direct`
|
||||||
|
- otherwise it falls back to any currently joined strict 1:1 DM with that user
|
||||||
|
- if no healthy DM exists, it creates a fresh direct room and rewrites `m.direct` to point at it
|
||||||
|
|
||||||
|
The repair flow does not delete old rooms automatically. It only picks the healthy DM and updates the mapping so new Matrix sends, verification notices, and other direct-message flows target the right room again.
|
||||||
|
|
||||||
## Threads
|
## Threads
|
||||||
|
|
||||||
- Reply threading is supported.
|
Matrix supports native Matrix threads for both automatic replies and message-tool sends.
|
||||||
- `channels.matrix.threadReplies` controls whether replies stay in threads:
|
|
||||||
- `off`, `inbound` (default), `always`
|
|
||||||
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
|
|
||||||
- `off` (default), `first`, `all`
|
|
||||||
|
|
||||||
## Capabilities
|
- `threadReplies: "off"` keeps replies top-level.
|
||||||
|
- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread.
|
||||||
|
- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message.
|
||||||
|
- Inbound threaded messages include the thread root message as extra agent context.
|
||||||
|
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
|
||||||
|
- Runtime thread bindings are supported for Matrix. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs.
|
||||||
|
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
|
||||||
|
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
|
||||||
|
|
||||||
| Feature | Status |
|
### Thread Binding Config
|
||||||
| --------------- | ------------------------------------------------------------------------------------- |
|
|
||||||
| Direct messages | ✅ Supported |
|
|
||||||
| Rooms | ✅ Supported |
|
|
||||||
| Threads | ✅ Supported |
|
|
||||||
| Media | ✅ Supported |
|
|
||||||
| E2EE | ✅ Supported (crypto module required) |
|
|
||||||
| Reactions | ✅ Supported (send/read via tools) |
|
|
||||||
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
|
|
||||||
| Location | ✅ Supported (geo URI; altitude ignored) |
|
|
||||||
| Native commands | ✅ Supported |
|
|
||||||
|
|
||||||
## Troubleshooting
|
Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
|
||||||
|
|
||||||
Run this ladder first:
|
- `threadBindings.enabled`
|
||||||
|
- `threadBindings.idleHours`
|
||||||
|
- `threadBindings.maxAgeHours`
|
||||||
|
- `threadBindings.spawnSubagentSessions`
|
||||||
|
- `threadBindings.spawnAcpSessions`
|
||||||
|
|
||||||
```bash
|
Matrix thread-bound spawn flags are opt-in:
|
||||||
openclaw status
|
|
||||||
openclaw gateway status
|
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
|
||||||
openclaw logs --follow
|
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
|
||||||
openclaw doctor
|
|
||||||
openclaw channels status --probe
|
## Reactions
|
||||||
|
|
||||||
|
Matrix supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
|
||||||
|
|
||||||
|
- Outbound reaction tooling is gated by `channels["matrix"].actions.reactions`.
|
||||||
|
- `react` adds a reaction to a specific Matrix event.
|
||||||
|
- `reactions` lists the current reaction summary for a specific Matrix event.
|
||||||
|
- `emoji=""` removes the bot account's own reactions on that event.
|
||||||
|
- `remove: true` removes only the specified emoji reaction from the bot account.
|
||||||
|
|
||||||
|
Ack reactions use the standard OpenClaw resolution order:
|
||||||
|
|
||||||
|
- `channels["matrix"].accounts.<accountId>.ackReaction`
|
||||||
|
- `channels["matrix"].ackReaction`
|
||||||
|
- `messages.ackReaction`
|
||||||
|
- agent identity emoji fallback
|
||||||
|
|
||||||
|
Ack reaction scope resolves in this order:
|
||||||
|
|
||||||
|
- `channels["matrix"].accounts.<accountId>.ackReactionScope`
|
||||||
|
- `channels["matrix"].ackReactionScope`
|
||||||
|
- `messages.ackReactionScope`
|
||||||
|
|
||||||
|
Reaction notification mode resolves in this order:
|
||||||
|
|
||||||
|
- `channels["matrix"].accounts.<accountId>.reactionNotifications`
|
||||||
|
- `channels["matrix"].reactionNotifications`
|
||||||
|
- default: `own`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages.
|
||||||
|
- `reactionNotifications: "off"` disables reaction system events.
|
||||||
|
- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals.
|
||||||
|
|
||||||
|
## DM and room policy example
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
dm: {
|
||||||
|
policy: "allowlist",
|
||||||
|
allowFrom: ["@admin:example.org"],
|
||||||
|
},
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groupAllowFrom: ["@admin:example.org"],
|
||||||
|
groups: {
|
||||||
|
"!roomid:example.org": {
|
||||||
|
requireMention: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then confirm DM pairing state if needed:
|
See [Groups](/channels/groups) for mention-gating and allowlist behavior.
|
||||||
|
|
||||||
|
Pairing example for Matrix DMs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw pairing list matrix
|
openclaw pairing list matrix
|
||||||
|
openclaw pairing approve matrix <CODE>
|
||||||
```
|
```
|
||||||
|
|
||||||
Common failures:
|
If an unapproved Matrix user keeps messaging you before approval, OpenClaw reuses the same pending pairing code and may send a reminder reply again after a short cooldown instead of minting a new code.
|
||||||
|
|
||||||
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
|
See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layout.
|
||||||
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
|
|
||||||
- Encrypted rooms fail: crypto support or encryption settings mismatch.
|
|
||||||
|
|
||||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
## Multi-account example
|
||||||
|
|
||||||
## Configuration reference (Matrix)
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
defaultAccount: "assistant",
|
||||||
|
dm: { policy: "pairing" },
|
||||||
|
accounts: {
|
||||||
|
assistant: {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "syt_assistant_xxx",
|
||||||
|
encryption: true,
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
homeserver: "https://matrix.example.org",
|
||||||
|
accessToken: "syt_alerts_xxx",
|
||||||
|
dm: {
|
||||||
|
policy: "allowlist",
|
||||||
|
allowFrom: ["@ops:example.org"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Full configuration: [Configuration](/gateway/configuration)
|
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
|
||||||
|
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
|
||||||
|
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
|
||||||
|
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.
|
||||||
|
|
||||||
Provider options:
|
## Private/LAN homeservers
|
||||||
|
|
||||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
By default, OpenClaw blocks private/internal Matrix homeservers for SSRF protection unless you
|
||||||
- `channels.matrix.homeserver`: homeserver URL.
|
explicitly opt in per account.
|
||||||
- `channels.matrix.userId`: Matrix user ID (optional with access token).
|
|
||||||
- `channels.matrix.accessToken`: access token.
|
If your homeserver runs on localhost, a LAN/Tailscale IP, or an internal hostname, enable
|
||||||
- `channels.matrix.password`: password for login (token stored).
|
`allowPrivateNetwork` for that Matrix account:
|
||||||
- `channels.matrix.deviceName`: device display name.
|
|
||||||
- `channels.matrix.encryption`: enable E2EE (default: false).
|
```json5
|
||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
{
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
channels: {
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
matrix: {
|
||||||
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
homeserver: "http://matrix-synapse:8008",
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
allowPrivateNetwork: true,
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
accessToken: "syt_internal_xxx",
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
},
|
||||||
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs).
|
},
|
||||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
}
|
||||||
- `channels.matrix.groups`: group allowlist + per-room settings map.
|
```
|
||||||
- `channels.matrix.rooms`: legacy group allowlist/config.
|
|
||||||
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
CLI setup example:
|
||||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
|
||||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
```bash
|
||||||
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
|
openclaw matrix account add \
|
||||||
- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
|
--account ops \
|
||||||
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
|
--homeserver http://matrix-synapse:8008 \
|
||||||
|
--allow-private-network \
|
||||||
|
--access-token syt_ops_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
This opt-in only allows trusted private/internal targets. Public cleartext homeservers such as
|
||||||
|
`http://matrix.example.org:8008` remain blocked. Prefer `https://` whenever possible.
|
||||||
|
|
||||||
|
## Target resolution
|
||||||
|
|
||||||
|
Matrix accepts these target forms anywhere OpenClaw asks you for a room or user target:
|
||||||
|
|
||||||
|
- Users: `@user:server`, `user:@user:server`, or `matrix:user:@user:server`
|
||||||
|
- Rooms: `!room:server`, `room:!room:server`, or `matrix:room:!room:server`
|
||||||
|
- Aliases: `#alias:server`, `channel:#alias:server`, or `matrix:channel:#alias:server`
|
||||||
|
|
||||||
|
Live directory lookup uses the logged-in Matrix account:
|
||||||
|
|
||||||
|
- User lookups query the Matrix user directory on that homeserver.
|
||||||
|
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
|
||||||
|
- Joined-room name lookup is best-effort. If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
- `enabled`: enable or disable the channel.
|
||||||
|
- `name`: optional label for the account.
|
||||||
|
- `defaultAccount`: preferred account ID when multiple Matrix accounts are configured.
|
||||||
|
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
|
||||||
|
- `allowPrivateNetwork`: allow this Matrix account to connect to private/internal homeservers. Enable this when the homeserver resolves to `localhost`, a LAN/Tailscale IP, or an internal host such as `matrix-synapse`.
|
||||||
|
- `userId`: full Matrix user ID, for example `@bot:example.org`.
|
||||||
|
- `accessToken`: access token for token-based auth.
|
||||||
|
- `password`: password for password-based login.
|
||||||
|
- `deviceId`: explicit Matrix device ID.
|
||||||
|
- `deviceName`: device display name for password login.
|
||||||
|
- `avatarUrl`: stored self-avatar URL for profile sync and `set-profile` updates.
|
||||||
|
- `initialSyncLimit`: startup sync event limit.
|
||||||
|
- `encryption`: enable E2EE.
|
||||||
|
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
|
||||||
|
- `groupPolicy`: `open`, `allowlist`, or `disabled`.
|
||||||
|
- `groupAllowFrom`: allowlist of user IDs for room traffic.
|
||||||
|
- `groupAllowFrom` entries should be full Matrix user IDs. Unresolved names are ignored at runtime.
|
||||||
|
- `replyToMode`: `off`, `first`, or `all`.
|
||||||
|
- `threadReplies`: `off`, `inbound`, or `always`.
|
||||||
|
- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle.
|
||||||
|
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
|
||||||
|
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
|
||||||
|
- `textChunkLimit`: outbound message chunk size.
|
||||||
|
- `chunkMode`: `length` or `newline`.
|
||||||
|
- `responsePrefix`: optional message prefix for outbound replies.
|
||||||
|
- `ackReaction`: optional ack reaction override for this channel/account.
|
||||||
|
- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`).
|
||||||
|
- `reactionNotifications`: inbound reaction notification mode (`own`, `off`).
|
||||||
|
- `mediaMaxMb`: outbound media size cap in MB.
|
||||||
|
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`). Default: `off`.
|
||||||
|
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. Alias entries are resolved to room IDs during invite handling; OpenClaw does not trust alias state claimed by the invited room.
|
||||||
|
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
|
||||||
|
- `dm.allowFrom` entries should be full Matrix user IDs unless you already resolved them through live directory lookup.
|
||||||
|
- `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries.
|
||||||
|
- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names.
|
||||||
|
- `rooms`: legacy alias for `groups`.
|
||||||
|
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `profile`, `memberInfo`, `channelInfo`, `verification`).
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
summary: "Microsoft Teams bot support status, capabilities, and configuration"
|
summary: "Microsoft Teams bot support status, capabilities, and configuration"
|
||||||
read_when:
|
read_when:
|
||||||
- Working on MS Teams channel features
|
- Working on Microsoft Teams channel features
|
||||||
title: "Microsoft Teams"
|
title: "Microsoft Teams"
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -17,9 +17,9 @@ Status: text + DM attachments are supported; channel/group file sending requires
|
|||||||
|
|
||||||
Microsoft Teams ships as a plugin and is not bundled with the core install.
|
Microsoft Teams ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
|
**Breaking change (2026.1.15):** Microsoft Teams moved out of core. If you use it, you must install the plugin.
|
||||||
|
|
||||||
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
|
Explainable: keeps core installs lighter and lets Microsoft Teams dependencies update independently.
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
@ -260,15 +260,17 @@ This is often easier than hand-editing JSON manifests.
|
|||||||
|
|
||||||
4. **Configure OpenClaw**
|
4. **Configure OpenClaw**
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"msteams": {
|
channels: {
|
||||||
"enabled": true,
|
msteams: {
|
||||||
"appId": "<APP_ID>",
|
enabled: true,
|
||||||
"appPassword": "<APP_PASSWORD>",
|
appId: "<APP_ID>",
|
||||||
"tenantId": "<TENANT_ID>",
|
appPassword: "<APP_PASSWORD>",
|
||||||
"webhook": { "port": 3978, "path": "/api/messages" }
|
tenantId: "<TENANT_ID>",
|
||||||
}
|
webhook: { port: 3978, path: "/api/messages" },
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -312,49 +314,49 @@ These are the **existing resourceSpecific permissions** in our Teams app manifes
|
|||||||
|
|
||||||
Minimal, valid example with the required fields. Replace IDs and URLs.
|
Minimal, valid example with the required fields. Replace IDs and URLs.
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
|
$schema: "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
|
||||||
"manifestVersion": "1.23",
|
manifestVersion: "1.23",
|
||||||
"version": "1.0.0",
|
version: "1.0.0",
|
||||||
"id": "00000000-0000-0000-0000-000000000000",
|
id: "00000000-0000-0000-0000-000000000000",
|
||||||
"name": { "short": "OpenClaw" },
|
name: { short: "OpenClaw" },
|
||||||
"developer": {
|
developer: {
|
||||||
"name": "Your Org",
|
name: "Your Org",
|
||||||
"websiteUrl": "https://example.com",
|
websiteUrl: "https://example.com",
|
||||||
"privacyUrl": "https://example.com/privacy",
|
privacyUrl: "https://example.com/privacy",
|
||||||
"termsOfUseUrl": "https://example.com/terms"
|
termsOfUseUrl: "https://example.com/terms",
|
||||||
},
|
},
|
||||||
"description": { "short": "OpenClaw in Teams", "full": "OpenClaw in Teams" },
|
description: { short: "OpenClaw in Teams", full: "OpenClaw in Teams" },
|
||||||
"icons": { "outline": "outline.png", "color": "color.png" },
|
icons: { outline: "outline.png", color: "color.png" },
|
||||||
"accentColor": "#5B6DEF",
|
accentColor: "#5B6DEF",
|
||||||
"bots": [
|
bots: [
|
||||||
{
|
{
|
||||||
"botId": "11111111-1111-1111-1111-111111111111",
|
botId: "11111111-1111-1111-1111-111111111111",
|
||||||
"scopes": ["personal", "team", "groupChat"],
|
scopes: ["personal", "team", "groupChat"],
|
||||||
"isNotificationOnly": false,
|
isNotificationOnly: false,
|
||||||
"supportsCalling": false,
|
supportsCalling: false,
|
||||||
"supportsVideo": false,
|
supportsVideo: false,
|
||||||
"supportsFiles": true
|
supportsFiles: true,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"webApplicationInfo": {
|
webApplicationInfo: {
|
||||||
"id": "11111111-1111-1111-1111-111111111111"
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
},
|
||||||
|
authorization: {
|
||||||
|
permissions: {
|
||||||
|
resourceSpecific: [
|
||||||
|
{ name: "ChannelMessage.Read.Group", type: "Application" },
|
||||||
|
{ name: "ChannelMessage.Send.Group", type: "Application" },
|
||||||
|
{ name: "Member.Read.Group", type: "Application" },
|
||||||
|
{ name: "Owner.Read.Group", type: "Application" },
|
||||||
|
{ name: "ChannelSettings.Read.Group", type: "Application" },
|
||||||
|
{ name: "TeamMember.Read.Group", type: "Application" },
|
||||||
|
{ name: "TeamSettings.Read.Group", type: "Application" },
|
||||||
|
{ name: "ChatMessage.Read.Chat", type: "Application" },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"authorization": {
|
|
||||||
"permissions": {
|
|
||||||
"resourceSpecific": [
|
|
||||||
{ "name": "ChannelMessage.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "ChannelMessage.Send.Group", "type": "Application" },
|
|
||||||
{ "name": "Member.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "Owner.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "ChannelSettings.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "TeamMember.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "TeamSettings.Read.Group", "type": "Application" },
|
|
||||||
{ "name": "ChatMessage.Read.Chat", "type": "Application" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -500,20 +502,22 @@ Teams recently introduced two channel UI styles over the same underlying data mo
|
|||||||
|
|
||||||
**Solution:** Configure `replyStyle` per-channel based on how the channel is set up:
|
**Solution:** Configure `replyStyle` per-channel based on how the channel is set up:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"msteams": {
|
channels: {
|
||||||
"replyStyle": "thread",
|
msteams: {
|
||||||
"teams": {
|
replyStyle: "thread",
|
||||||
|
teams: {
|
||||||
"19:abc...@thread.tacv2": {
|
"19:abc...@thread.tacv2": {
|
||||||
"channels": {
|
channels: {
|
||||||
"19:xyz...@thread.tacv2": {
|
"19:xyz...@thread.tacv2": {
|
||||||
"replyStyle": "top-level"
|
replyStyle: "top-level",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -616,16 +620,16 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
|
|||||||
|
|
||||||
**Agent tool:**
|
**Agent tool:**
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"action": "send",
|
action: "send",
|
||||||
"channel": "msteams",
|
channel: "msteams",
|
||||||
"target": "user:<id>",
|
target: "user:<id>",
|
||||||
"card": {
|
card: {
|
||||||
"type": "AdaptiveCard",
|
type: "AdaptiveCard",
|
||||||
"version": "1.5",
|
version: "1.5",
|
||||||
"body": [{ "type": "TextBlock", "text": "Hello!" }]
|
body: [{ type: "TextBlock", text: "Hello!" }],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -669,25 +673,25 @@ openclaw message send --channel msteams --target "conversation:19:abc...@thread.
|
|||||||
|
|
||||||
**Agent tool examples:**
|
**Agent tool examples:**
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"action": "send",
|
action: "send",
|
||||||
"channel": "msteams",
|
channel: "msteams",
|
||||||
"target": "user:John Smith",
|
target: "user:John Smith",
|
||||||
"message": "Hello!"
|
message: "Hello!",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"action": "send",
|
action: "send",
|
||||||
"channel": "msteams",
|
channel: "msteams",
|
||||||
"target": "conversation:19:abc...@thread.tacv2",
|
target: "conversation:19:abc...@thread.tacv2",
|
||||||
"card": {
|
card: {
|
||||||
"type": "AdaptiveCard",
|
type: "AdaptiveCard",
|
||||||
"version": "1.5",
|
version: "1.5",
|
||||||
"body": [{ "type": "TextBlock", "text": "Hello" }]
|
body: [{ type: "TextBlock", text: "Hello" }],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -60,13 +60,13 @@ nak key generate
|
|||||||
|
|
||||||
2. Add to config:
|
2. Add to config:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"nostr": {
|
nostr: {
|
||||||
"privateKey": "${NOSTR_PRIVATE_KEY}"
|
privateKey: "${NOSTR_PRIVATE_KEY}",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -96,23 +96,23 @@ Profile data is published as a NIP-01 `kind:0` event. You can manage it from the
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"nostr": {
|
nostr: {
|
||||||
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
privateKey: "${NOSTR_PRIVATE_KEY}",
|
||||||
"profile": {
|
profile: {
|
||||||
"name": "openclaw",
|
name: "openclaw",
|
||||||
"displayName": "OpenClaw",
|
displayName: "OpenClaw",
|
||||||
"about": "Personal assistant DM bot",
|
about: "Personal assistant DM bot",
|
||||||
"picture": "https://example.com/avatar.png",
|
picture: "https://example.com/avatar.png",
|
||||||
"banner": "https://example.com/banner.png",
|
banner: "https://example.com/banner.png",
|
||||||
"website": "https://example.com",
|
website: "https://example.com",
|
||||||
"nip05": "openclaw@example.com",
|
nip05: "openclaw@example.com",
|
||||||
"lud16": "openclaw@example.com"
|
lud16: "openclaw@example.com",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -132,15 +132,15 @@ Notes:
|
|||||||
|
|
||||||
### Allowlist example
|
### Allowlist example
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"nostr": {
|
nostr: {
|
||||||
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
privateKey: "${NOSTR_PRIVATE_KEY}",
|
||||||
"dmPolicy": "allowlist",
|
dmPolicy: "allowlist",
|
||||||
"allowFrom": ["npub1abc...", "npub1xyz..."]
|
allowFrom: ["npub1abc...", "npub1xyz..."],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -155,14 +155,14 @@ Accepted formats:
|
|||||||
|
|
||||||
Defaults: `relay.damus.io` and `nos.lol`.
|
Defaults: `relay.damus.io` and `nos.lol`.
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"nostr": {
|
nostr: {
|
||||||
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
privateKey: "${NOSTR_PRIVATE_KEY}",
|
||||||
"relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"]
|
relays: ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -191,14 +191,14 @@ Tips:
|
|||||||
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
docker run -p 7777:7777 ghcr.io/hoytech/strfry
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
channels: {
|
||||||
"nostr": {
|
nostr: {
|
||||||
"privateKey": "${NOSTR_PRIVATE_KEY}",
|
privateKey: "${NOSTR_PRIVATE_KEY}",
|
||||||
"relays": ["ws://localhost:7777"]
|
relays: ["ws://localhost:7777"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
|
|||||||
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
|
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
|
||||||
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
|
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
|
||||||
4. Paste the setup code and connect.
|
4. Paste the setup code and connect.
|
||||||
5. Back in Telegram: `/pair approve`
|
5. Back in Telegram: `/pair pending` (review request IDs, role, and scopes), then approve.
|
||||||
|
|
||||||
The setup code is a base64-encoded JSON payload that contains:
|
The setup code is a base64-encoded JSON payload that contains:
|
||||||
|
|
||||||
@ -84,6 +84,10 @@ openclaw devices approve <requestId>
|
|||||||
openclaw devices reject <requestId>
|
openclaw devices reject <requestId>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the same device retries with different auth details (for example different
|
||||||
|
role/scopes/public key), the previous pending request is superseded and a new
|
||||||
|
`requestId` is created.
|
||||||
|
|
||||||
### Node pairing state storage
|
### Node pairing state storage
|
||||||
|
|
||||||
Stored under `~/.openclaw/devices/`:
|
Stored under `~/.openclaw/devices/`:
|
||||||
|
|||||||
@ -99,7 +99,7 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern.
|
||||||
|
|
||||||
## Setup path B: register dedicated bot number (SMS, Linux)
|
## Setup path B: register dedicated bot number (SMS, Linux)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user