mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:41:16 +08:00
Compare commits
305 Commits
fix/codex-
...
fix/gitign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c4eb7d26 | ||
|
|
b7be3f614a | ||
|
|
bda63c3c7f | ||
|
|
aca216bfcf | ||
|
|
c2eb12bbc5 | ||
|
|
6d0547dc2e | ||
|
|
568b0a22bb | ||
|
|
450d49ea52 | ||
|
|
3495563cfe | ||
|
|
9d403fd415 | ||
|
|
5296147c20 | ||
|
|
8306eabf85 | ||
|
|
45b74fb56c | ||
|
|
d1a59557b5 | ||
|
|
cf9db91b61 | ||
|
|
382287026b | ||
|
|
da4fec6641 | ||
|
|
96e4975922 | ||
|
|
989ee21b24 | ||
|
|
705c6a422d | ||
|
|
f0eb67923c | ||
|
|
93c44e3dad | ||
|
|
e42c4f4513 | ||
|
|
391f9430ca | ||
|
|
e74666cd0a | ||
|
|
731f1aa906 | ||
|
|
de49a8b72c | ||
|
|
9432a8bb3f | ||
|
|
25c2facc2b | ||
|
|
1720174757 | ||
|
|
5decb00e9d | ||
|
|
6b87489890 | ||
|
|
9f0a64f855 | ||
|
|
8e412bad0e | ||
|
|
8a6cd808a1 | ||
|
|
d648dd7643 | ||
|
|
5a659b0b61 | ||
|
|
c0cba7fb72 | ||
|
|
b48291e01e | ||
|
|
4790e40ac6 | ||
|
|
c9a6c542ef | ||
|
|
de4c3db3e3 | ||
|
|
64746c150c | ||
|
|
56f787e3c0 | ||
|
|
531e8362b1 | ||
|
|
3c3474360b | ||
|
|
0669b0ddc2 | ||
|
|
0c7f07818f | ||
|
|
4aebff78bc | ||
|
|
8e3f3bc3cf | ||
|
|
30340d6835 | ||
|
|
d346f2d9ce | ||
|
|
e6e4169e82 | ||
|
|
1bc59cc09d | ||
|
|
ef95975411 | ||
|
|
5f90883ad3 | ||
|
|
2b2e5e2038 | ||
|
|
0bcddb3d4f | ||
|
|
d86647d7db | ||
|
|
87d939be79 | ||
|
|
d4e59a3666 | ||
|
|
7b88249c9e | ||
|
|
12702e11a5 | ||
|
|
14bbcad169 | ||
|
|
eab39c721b | ||
|
|
4815dc0603 | ||
|
|
2cce45962f | ||
|
|
258b7902a4 | ||
|
|
425bd89b48 | ||
|
|
54be30ef89 | ||
|
|
fbf5d56366 | ||
|
|
98ea71aca5 | ||
|
|
51bae75120 | ||
|
|
f2f561fab1 | ||
|
|
f6d0712f50 | ||
|
|
6c579d7842 | ||
|
|
f9706fde6a | ||
|
|
7217b97658 | ||
|
|
ce9e91fdfc | ||
|
|
3caab9260c | ||
|
|
d0847ee322 | ||
|
|
1d3dde8d21 | ||
|
|
cc0f30f5fb | ||
|
|
250d3c949e | ||
|
|
5fca4c0de0 | ||
|
|
66c581c64c | ||
|
|
912aa8744a | ||
|
|
8d2d6db9ad | ||
|
|
2d55ad05f3 | ||
|
|
9631f4665c | ||
|
|
e2a1a4a3db | ||
|
|
f82931ba8b | ||
|
|
17599a8ea2 | ||
|
|
e86b38f09d | ||
|
|
1d301f74a6 | ||
|
|
2e79d82198 | ||
|
|
96d17f3cb1 | ||
|
|
79853aca9c | ||
|
|
2d5e70f3e7 | ||
|
|
6186f620d2 | ||
|
|
2767907abf | ||
|
|
9abf014f35 | ||
|
|
cf3a479bd1 | ||
|
|
fd902b0651 | ||
|
|
cf796e2a22 | ||
|
|
f84adcbe88 | ||
|
|
f184e7811c | ||
|
|
c79a0dbdb4 | ||
|
|
335223af32 | ||
|
|
6740cdf160 | ||
|
|
eea925b12b | ||
|
|
88aee9161e | ||
|
|
03a6e3b460 | ||
|
|
41e023a80b | ||
|
|
93775ef6a4 | ||
|
|
31402b8542 | ||
|
|
4bb8104810 | ||
|
|
1d6a2d0165 | ||
|
|
44beb7be1f | ||
|
|
69cd376e3b | ||
|
|
41eef15cdc | ||
|
|
41450187dd | ||
|
|
a40c29b11a | ||
|
|
d4a960fcca | ||
|
|
26e76f9a61 | ||
|
|
8befd88119 | ||
|
|
99cbda83a2 | ||
|
|
e8775cda93 | ||
|
|
ef36cb8cbc | ||
|
|
f114a5c638 | ||
|
|
a438ff4397 | ||
|
|
adec8b28bb | ||
|
|
e3df94365b | ||
|
|
4d501e4ccf | ||
|
|
f6243916b5 | ||
|
|
b34158086a | ||
|
|
eabda6e3a4 | ||
|
|
6d5e142b93 | ||
|
|
4f42c03a49 | ||
|
|
13bd3db307 | ||
|
|
ff4745fc3f | ||
|
|
c29b098744 | ||
|
|
24b53fcf47 | ||
|
|
dfc18b7a2b | ||
|
|
141738f717 | ||
|
|
4ff4ed7ec9 | ||
|
|
362248e559 | ||
|
|
d47aa6bae8 | ||
|
|
661af2acd3 | ||
|
|
936ac22ec2 | ||
|
|
bf601db3fc | ||
|
|
5845b5bfba | ||
|
|
52a253f18c | ||
|
|
3f2f007c9a | ||
|
|
32a6eae576 | ||
|
|
8d7778d1d6 | ||
|
|
3e70109cb2 | ||
|
|
024857050a | ||
|
|
3da8882a02 | ||
|
|
b2b99f0325 | ||
|
|
a3dc4b5a57 | ||
|
|
211f68f8ad | ||
|
|
3f3f66a5f7 | ||
|
|
bd1fe4d8b4 | ||
|
|
3ea3a1c0ca | ||
|
|
da6592b681 | ||
|
|
abb8f63107 | ||
|
|
e806c479f5 | ||
|
|
38543d8196 | ||
|
|
7dfd77abeb | ||
|
|
5889a2e98e | ||
|
|
09acbe6528 | ||
|
|
64dd23eade | ||
|
|
dadd7f99cd | ||
|
|
0ecfd37b44 | ||
|
|
a075baba84 | ||
|
|
a6131438ea | ||
|
|
92726d9863 | ||
|
|
3d3e8fe78c | ||
|
|
3b7a72bffb | ||
|
|
37e0b01684 | ||
|
|
bd0e6a6efd | ||
|
|
6b338dd283 | ||
|
|
9d467d1620 | ||
|
|
d3111fbbcb | ||
|
|
e883d0b556 | ||
|
|
436ae8a07c | ||
|
|
0692f71c6f | ||
|
|
bcb0d1b8b4 | ||
|
|
dcdce83da7 | ||
|
|
dfa3605bee | ||
|
|
4bfa800cc7 | ||
|
|
9914b48c57 | ||
|
|
4d904e7b7d | ||
|
|
7b58507224 | ||
|
|
c1f6edf48b | ||
|
|
8b2f40f5f6 | ||
|
|
f9c220e261 | ||
|
|
75602014db | ||
|
|
3cf75f760c | ||
|
|
ae39a152d8 | ||
|
|
efa1204183 | ||
|
|
9a4610c641 | ||
|
|
c0a988f692 | ||
|
|
641e1bacb4 | ||
|
|
0252bdc837 | ||
|
|
885199dcaa | ||
|
|
3ada30e670 | ||
|
|
c5095153b0 | ||
|
|
68775745d2 | ||
|
|
f399a818ef | ||
|
|
6bd5735519 | ||
|
|
11be305609 | ||
|
|
f6cb77134c | ||
|
|
25d0aa7296 | ||
|
|
dd7470730d | ||
|
|
c70151e873 | ||
|
|
a007bed375 | ||
|
|
fa580e33c1 | ||
|
|
371c53b282 | ||
|
|
cee2f3e8b4 | ||
|
|
2ed644f5d3 | ||
|
|
404b1527e6 | ||
|
|
72ebaf97c3 | ||
|
|
8ab762c005 | ||
|
|
d307a7ca1a | ||
|
|
52bc809143 | ||
|
|
6094035054 | ||
|
|
f493b03202 | ||
|
|
e53d840fed | ||
|
|
f66bd105a4 | ||
|
|
ef2541ceb3 | ||
|
|
8a18e2598f | ||
|
|
749eb4efea | ||
|
|
64d4d9aabb | ||
|
|
e5c06dd64a | ||
|
|
efcca3d2ea | ||
|
|
0b452a5665 | ||
|
|
4c71176c9f | ||
|
|
c5bba6628e | ||
|
|
3b68d3fded | ||
|
|
7856f5730c | ||
|
|
aebfce7a36 | ||
|
|
e19b3679d1 | ||
|
|
d23d36a2f9 | ||
|
|
2ae58542a0 | ||
|
|
55465d86d9 | ||
|
|
615466bdf4 | ||
|
|
6f4de3cc23 | ||
|
|
f19761cefa | ||
|
|
5387faa718 | ||
|
|
bdf9739e59 | ||
|
|
2970d72554 | ||
|
|
74624e619d | ||
|
|
6c9b49a10b | ||
|
|
caf1b84822 | ||
|
|
b6520d7172 | ||
|
|
d4ab731746 | ||
|
|
95dff166cb | ||
|
|
1ec1f0f1f2 | ||
|
|
bce9d93fb5 | ||
|
|
bec3c0b71d | ||
|
|
b41bcb08a2 | ||
|
|
75e1521660 | ||
|
|
79c5c660bb | ||
|
|
fa00b1d0ca | ||
|
|
032778fb2e | ||
|
|
16a5f0b006 | ||
|
|
dc5645d459 | ||
|
|
8d3d742c6a | ||
|
|
87640f9a61 | ||
|
|
b7ad8fd661 | ||
|
|
ca5e352c53 | ||
|
|
c942655451 | ||
|
|
fa83010b17 | ||
|
|
67b2e81360 | ||
|
|
28e46d04e5 | ||
|
|
d9e8e8ac15 | ||
|
|
da3cccb212 | ||
|
|
e8ad80afc7 | ||
|
|
b4c8950417 | ||
|
|
4e2290b841 | ||
|
|
4f482d2a2b | ||
|
|
eba9dcc67a | ||
|
|
27558806b5 | ||
|
|
0af3118d08 | ||
|
|
6ff7e8f42e | ||
|
|
097c588a6b | ||
|
|
2bf53c2cb6 | ||
|
|
e2c07f8a47 | ||
|
|
1a364cd066 | ||
|
|
9ce79bba34 | ||
|
|
047f4acacf | ||
|
|
64760614aa | ||
|
|
76e4b8277f | ||
|
|
6dadfaa18c | ||
|
|
d5b305b250 | ||
|
|
ba2d580c4e | ||
|
|
acac7e3132 | ||
|
|
8a1015f1aa | ||
|
|
38f4ac5e3c | ||
|
|
d91d24e41d | ||
|
|
d2347ed825 | ||
|
|
6e086a5b3b | ||
|
|
c9f2d6b761 |
@@ -41,3 +41,5 @@ pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bash
|
||||
pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},
|
||||
pattern = "ap[i]Key": "xxxxx",
|
||||
pattern = ap[i]Key: "A[I]za\.\.\.",
|
||||
# Sparkle appcast signatures are release metadata, not credentials.
|
||||
pattern = sparkle:edSignature="[A-Za-z0-9+/=]+"
|
||||
|
||||
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -76,6 +76,37 @@ body:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Effective model under test.
|
||||
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: provider_chain
|
||||
attributes:
|
||||
label: Provider / routing chain
|
||||
description: Effective request path through gateways, proxies, providers, or model routers.
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
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
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
label: Additional provider/model setup details
|
||||
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
|
||||
placeholder: |
|
||||
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
|
||||
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
|
||||
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
18
.github/codeql/codeql-javascript-typescript.yml
vendored
Normal file
18
.github/codeql/codeql-javascript-typescript.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: openclaw-codeql-javascript-typescript
|
||||
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- ui/src
|
||||
- skills
|
||||
|
||||
paths-ignore:
|
||||
- apps
|
||||
- dist
|
||||
- docs
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@@ -87,6 +87,13 @@ What you personally verified (not just CI), and how:
|
||||
- Edge cases checked:
|
||||
- What you did **not** verify:
|
||||
|
||||
## Review Conversations
|
||||
|
||||
- [ ] I replied to or resolved every bot review conversation I addressed in this PR.
|
||||
- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment.
|
||||
|
||||
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
|
||||
7
.github/workflows/auto-response.yml
vendored
7
.github/workflows/auto-response.yml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
},
|
||||
{
|
||||
label: "r: no-ci-pr",
|
||||
close: true,
|
||||
message:
|
||||
"Please don't make PRs for test failures on main.\n\n" +
|
||||
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
|
||||
@@ -261,6 +262,8 @@ jobs:
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
@@ -448,6 +451,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
|
||||
labelSet.delete(activePrLimitLabel);
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -267,6 +267,12 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Ensure secrets base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
|
||||
12
.github/workflows/codeql.yml
vendored
12
.github/workflows/codeql.yml
vendored
@@ -28,6 +28,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ./.github/codeql/codeql-javascript-typescript.yml
|
||||
- language: actions
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -36,6 +37,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: python
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -44,6 +46,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: java-kotlin
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -52,6 +55,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: swift
|
||||
runs_on: macos-latest
|
||||
needs_node: false
|
||||
@@ -60,6 +64,7 @@ jobs:
|
||||
needs_swift_tools: true
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -88,13 +93,18 @@ jobs:
|
||||
|
||||
- name: Setup Swift build tools
|
||||
if: matrix.needs_swift_tools
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
config-file: ${{ matrix.config_file || '' }}
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.needs_autobuild
|
||||
|
||||
28
.github/workflows/labeler.yml
vendored
28
.github/workflows/labeler.yml
vendored
@@ -213,6 +213,7 @@ jobs:
|
||||
}
|
||||
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const activePrLimit = 10;
|
||||
const labelColor = "B60205";
|
||||
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
|
||||
@@ -221,12 +222,37 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
(pullRequest.labels ?? [])
|
||||
currentLabels
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
if (labelNames.has(activePrLimitOverrideLabel)) {
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureLabelExists = async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -121,3 +121,4 @@ dist/protocol.schema.json
|
||||
|
||||
# Synthing
|
||||
**/.stfolder/
|
||||
.dev-state
|
||||
|
||||
@@ -9,7 +9,19 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
|
||||
0. Truthfulness + reality gate (required for bug-fix claims)
|
||||
- Do not trust the issue text or PR summary by default; verify in code and evidence.
|
||||
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
|
||||
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
|
||||
- Verify fix targets the same code path as the root cause.
|
||||
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
|
||||
- Hallucination/BS red flags (treat as BLOCKER until disproven):
|
||||
- claimed behavior not present in repo,
|
||||
- issue/PR says "fixes #..." but changed files do not touch implicated path,
|
||||
- only docs/comments changed for a runtime bug claim,
|
||||
- vague AI-generated rationale without concrete evidence.
|
||||
|
||||
1. Identify PR meta + context
|
||||
|
||||
@@ -56,6 +68,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
@@ -65,18 +78,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
|
||||
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) What changed
|
||||
B) Claim verification matrix (required)
|
||||
|
||||
- Fill this table:
|
||||
|
||||
| Field | Evidence |
|
||||
| ----------------------------------------------- | -------- |
|
||||
| Claimed problem | ... |
|
||||
| Evidence observed (repro/log/test/code) | ... |
|
||||
| Root cause location (`path:line`) | ... |
|
||||
| Why this fix addresses that root cause | ... |
|
||||
| Regression coverage (test name or manual proof) | ... |
|
||||
|
||||
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
|
||||
|
||||
C) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
C) What's good
|
||||
D) What's good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
D) Concerns / questions (actionable)
|
||||
E) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
@@ -84,17 +111,19 @@ D) Concerns / questions (actionable)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
|
||||
|
||||
E) Tests
|
||||
F) Tests
|
||||
|
||||
- What exists.
|
||||
- What's missing (specific scenarios).
|
||||
- State clearly whether there is a regression test for the claimed bug.
|
||||
|
||||
F) Follow-ups (optional)
|
||||
G) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
G) Suggested PR comment (optional)
|
||||
H) Suggested PR comment (optional)
|
||||
|
||||
- Offer: "Want me to draft a PR comment to the author?"
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
@@ -66,9 +66,13 @@ repos:
|
||||
- --exclude-lines
|
||||
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "xxxxx",'
|
||||
- '"ap[i]Key": "xxxxx"(,)?'
|
||||
- --exclude-lines
|
||||
- 'ap[i]Key: "A[I]za\.\.\.",'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?'
|
||||
- --exclude-lines
|
||||
- 'sparkle:edSignature="[A-Za-z0-9+/=]+"'
|
||||
# Shell script linting
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.11.0
|
||||
|
||||
@@ -151,8 +151,10 @@
|
||||
"export CUSTOM_API_K[E]Y=\"your-key\"",
|
||||
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
|
||||
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
|
||||
"\"ap[i]Key\": \"xxxxx\",",
|
||||
"ap[i]Key: \"A[I]za\\.\\.\\.\","
|
||||
"\"ap[i]Key\": \"xxxxx\"(,)?",
|
||||
"ap[i]Key: \"A[I]za\\.\\.\\.\",",
|
||||
"\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?",
|
||||
"sparkle:edSignature=\"[A-Za-z0-9+/=]+\""
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -179,29 +181,6 @@
|
||||
"line_number": 15
|
||||
}
|
||||
],
|
||||
"appcast.xml": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "7afea670e53d801f1f881c99c40aa177e3395bfa",
|
||||
"is_verified": false,
|
||||
"line_number": 365
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "6e1ba26139ac4e73427e68a7eec2abf96bcf1fd4",
|
||||
"is_verified": false,
|
||||
"line_number": 584
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "c0baa9660a8d3b11874c63a535d8369f4a8fa8fa",
|
||||
"is_verified": false,
|
||||
"line_number": 723
|
||||
}
|
||||
],
|
||||
"apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
@@ -226,7 +205,7 @@
|
||||
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1749
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
@@ -251,7 +230,7 @@
|
||||
"filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift",
|
||||
"hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4",
|
||||
"is_verified": false,
|
||||
"line_number": 66
|
||||
"line_number": 81
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [
|
||||
@@ -287,7 +266,7 @@
|
||||
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1749
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"docs/.i18n/zh-CN.tm.jsonl": [
|
||||
@@ -9619,14 +9598,14 @@
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
|
||||
"is_verified": false,
|
||||
"line_number": 189
|
||||
"line_number": 187
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
|
||||
"is_verified": false,
|
||||
"line_number": 501
|
||||
"line_number": 499
|
||||
}
|
||||
],
|
||||
"docs/channels/irc.md": [
|
||||
@@ -9795,63 +9774,63 @@
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
||||
"is_verified": false,
|
||||
"line_number": 1612
|
||||
"line_number": 1614
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770",
|
||||
"is_verified": false,
|
||||
"line_number": 1628
|
||||
"line_number": 1630
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
|
||||
"is_verified": false,
|
||||
"line_number": 1813
|
||||
"line_number": 1817
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
||||
"is_verified": false,
|
||||
"line_number": 1986
|
||||
"line_number": 1990
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
||||
"is_verified": false,
|
||||
"line_number": 2042
|
||||
"line_number": 2046
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 2274
|
||||
"line_number": 2278
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
||||
"is_verified": false,
|
||||
"line_number": 2402
|
||||
"line_number": 2408
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
||||
"is_verified": false,
|
||||
"line_number": 2655
|
||||
"line_number": 2661
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
|
||||
"is_verified": false,
|
||||
"line_number": 2657
|
||||
"line_number": 2663
|
||||
}
|
||||
],
|
||||
"docs/gateway/configuration.md": [
|
||||
@@ -9972,7 +9951,7 @@
|
||||
"filename": "docs/perplexity.md",
|
||||
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
|
||||
"is_verified": false,
|
||||
"line_number": 29
|
||||
"line_number": 43
|
||||
}
|
||||
],
|
||||
"docs/plugins/voice-call.md": [
|
||||
@@ -10198,21 +10177,21 @@
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
|
||||
"is_verified": false,
|
||||
"line_number": 90
|
||||
"line_number": 135
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
|
||||
"is_verified": false,
|
||||
"line_number": 179
|
||||
"line_number": 228
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
|
||||
"is_verified": false,
|
||||
"line_number": 277
|
||||
"line_number": 332
|
||||
}
|
||||
],
|
||||
"docs/tts.md": [
|
||||
@@ -10255,14 +10234,14 @@
|
||||
"filename": "docs/zh-CN/channels/feishu.md",
|
||||
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
|
||||
"is_verified": false,
|
||||
"line_number": 195
|
||||
"line_number": 191
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/zh-CN/channels/feishu.md",
|
||||
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
|
||||
"is_verified": false,
|
||||
"line_number": 509
|
||||
"line_number": 505
|
||||
}
|
||||
],
|
||||
"docs/zh-CN/channels/line.md": [
|
||||
@@ -11481,7 +11460,7 @@
|
||||
"filename": "src/agents/models-config.e2e-harness.ts",
|
||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
||||
"is_verified": false,
|
||||
"line_number": 130
|
||||
"line_number": 157
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
|
||||
@@ -11515,14 +11494,14 @@
|
||||
"filename": "src/agents/models-config.providers.nvidia.test.ts",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 13
|
||||
"line_number": 14
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.providers.nvidia.test.ts",
|
||||
"hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd",
|
||||
"is_verified": false,
|
||||
"line_number": 22
|
||||
"line_number": 23
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.providers.ollama.e2e.test.ts": [
|
||||
@@ -11583,7 +11562,7 @@
|
||||
"filename": "src/agents/pi-embedded-runner/model.ts",
|
||||
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
|
||||
"is_verified": false,
|
||||
"line_number": 272
|
||||
"line_number": 279
|
||||
}
|
||||
],
|
||||
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
|
||||
@@ -11680,7 +11659,7 @@
|
||||
"filename": "src/agents/tools/web-search.ts",
|
||||
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
|
||||
"is_verified": false,
|
||||
"line_number": 254
|
||||
"line_number": 291
|
||||
}
|
||||
],
|
||||
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
|
||||
@@ -11746,7 +11725,7 @@
|
||||
"filename": "src/auto-reply/status.test.ts",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 36
|
||||
"line_number": 37
|
||||
}
|
||||
],
|
||||
"src/browser/bridge-server.auth.test.ts": [
|
||||
@@ -11764,14 +11743,14 @@
|
||||
"filename": "src/browser/browser-utils.test.ts",
|
||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
||||
"is_verified": false,
|
||||
"line_number": 43
|
||||
"line_number": 47
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "src/browser/browser-utils.test.ts",
|
||||
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
||||
"is_verified": false,
|
||||
"line_number": 164
|
||||
"line_number": 171
|
||||
}
|
||||
],
|
||||
"src/browser/cdp.test.ts": [
|
||||
@@ -11780,7 +11759,7 @@
|
||||
"filename": "src/browser/cdp.test.ts",
|
||||
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
|
||||
"is_verified": false,
|
||||
"line_number": 243
|
||||
"line_number": 318
|
||||
}
|
||||
],
|
||||
"src/channels/plugins/plugins-channel.test.ts": [
|
||||
@@ -12100,21 +12079,21 @@
|
||||
"filename": "src/config/config.env-vars.test.ts",
|
||||
"hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36",
|
||||
"is_verified": false,
|
||||
"line_number": 13
|
||||
"line_number": 17
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/config.env-vars.test.ts",
|
||||
"hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3",
|
||||
"is_verified": false,
|
||||
"line_number": 19
|
||||
"line_number": 23
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/config.env-vars.test.ts",
|
||||
"hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80",
|
||||
"is_verified": false,
|
||||
"line_number": 27
|
||||
"line_number": 31
|
||||
}
|
||||
],
|
||||
"src/config/config.irc.test.ts": [
|
||||
@@ -12335,14 +12314,14 @@
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
|
||||
"is_verified": false,
|
||||
"line_number": 649
|
||||
"line_number": 653
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
|
||||
"is_verified": false,
|
||||
"line_number": 680
|
||||
"line_number": 686
|
||||
}
|
||||
],
|
||||
"src/config/schema.irc.ts": [
|
||||
@@ -12381,14 +12360,14 @@
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
|
||||
"is_verified": false,
|
||||
"line_number": 216
|
||||
"line_number": 217
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
|
||||
"is_verified": false,
|
||||
"line_number": 324
|
||||
"line_number": 326
|
||||
}
|
||||
],
|
||||
"src/config/slack-http-config.test.ts": [
|
||||
@@ -12932,14 +12911,14 @@
|
||||
"filename": "src/telegram/monitor.test.ts",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 450
|
||||
"line_number": 497
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/telegram/monitor.test.ts",
|
||||
"hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7",
|
||||
"is_verified": false,
|
||||
"line_number": 641
|
||||
"line_number": 688
|
||||
}
|
||||
],
|
||||
"src/telegram/webhook.test.ts": [
|
||||
@@ -13034,5 +13013,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-08T05:05:36Z"
|
||||
"generated_at": "2026-03-10T03:11:06Z"
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
@@ -18,7 +18,9 @@ excluded:
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
|
||||
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
|
||||
# Generated (generate-host-env-security-policy-swift.mjs)
|
||||
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
|
||||
32
AGENTS.md
32
AGENTS.md
@@ -6,9 +6,39 @@
|
||||
- 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.
|
||||
|
||||
## 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).
|
||||
- `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
|
||||
|
||||
- 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`).
|
||||
@@ -28,6 +58,7 @@
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- When working with documentation, read the mintlify skill.
|
||||
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
@@ -113,6 +144,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
- `/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.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
|
||||
122
CHANGELOG.md
122
CHANGELOG.md
@@ -2,19 +2,130 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
||||
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
||||
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
|
||||
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
||||
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
||||
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
|
||||
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
|
||||
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
|
||||
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
|
||||
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
||||
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
|
||||
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
||||
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
||||
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
||||
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
||||
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
|
||||
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
|
||||
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
||||
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
||||
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
|
||||
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
|
||||
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
|
||||
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
|
||||
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
|
||||
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
|
||||
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
|
||||
- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
|
||||
- Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.
|
||||
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
|
||||
- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update/macOS launchd restart: re-enable disabled LaunchAgent services before updater bootstrap so `openclaw update` can recover from a disabled gateway service instead of leaving the restart step stuck.
|
||||
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
|
||||
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
|
||||
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
|
||||
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
|
||||
- Agents/openai-codex model resolution: fall through from inline `openai-codex` model entries without an `api` so GPT-5.4 keeps the codex transport and still preserves configured `baseUrl` and headers. (#39753) Thanks @justinhuangcode.
|
||||
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
|
||||
- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus.
|
||||
- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
|
||||
- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.
|
||||
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
|
||||
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
|
||||
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.
|
||||
- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
|
||||
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
|
||||
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
|
||||
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
|
||||
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
|
||||
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
|
||||
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
|
||||
- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.
|
||||
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
|
||||
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
|
||||
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
|
||||
- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.
|
||||
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
|
||||
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
|
||||
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
|
||||
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
|
||||
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
|
||||
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
|
||||
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
|
||||
- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
|
||||
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
|
||||
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
|
||||
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
|
||||
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
|
||||
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
|
||||
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
|
||||
- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
|
||||
- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
|
||||
- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
|
||||
- Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.
|
||||
- Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.
|
||||
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
|
||||
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
|
||||
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
|
||||
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
|
||||
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
|
||||
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
@@ -47,6 +158,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
|
||||
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
|
||||
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
|
||||
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -99,6 +211,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
|
||||
- Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
|
||||
- iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
|
||||
- Cron/manual run enqueue flow: queue `cron.run` requests behind the cron execution lane, return immediate `{ ok: true, enqueued: true, runId }` acknowledgements, preserve `{ ok: true, ran: false, reason }` skip responses for already-running and not-due jobs, and document the asynchronous completion flow. (#40204)
|
||||
- Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
|
||||
- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
|
||||
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
|
||||
@@ -370,6 +483,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
|
||||
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
|
||||
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
||||
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
@@ -728,6 +843,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade.
|
||||
- Plugin SDK/bundled subpath contracts: add regression coverage for newly routed bundled-plugin SDK exports so BlueBubbles, Mattermost, Nextcloud Talk, and Twitch subpath symbols stay pinned during future plugin-sdk cleanup. (#39638)
|
||||
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
|
||||
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
|
||||
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
|
||||
@@ -57,9 +57,21 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
- **Radek Sienkiewicz** - Control UI + WebChat correctness
|
||||
- GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark)
|
||||
|
||||
- **Muhammed Mukhthar** - Mattermost, CLI
|
||||
- GitHub [@mukhtharcm](https://github.com/mukhtharcm) · X: [@mukhtharcm](https://x.com/mukhtharcm)
|
||||
|
||||
- **Altay** - Agents, CLI, error handling
|
||||
- GitHub [@altaywtf](https://github.com/altaywtf) · X: [@altaywtf](https://x.com/altaywtf)
|
||||
|
||||
- **Robin Waslander** - Security, PR triage, bug fixes
|
||||
- GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
@@ -74,8 +86,19 @@ Welcome to the lobster tank! 🦞
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
|
||||
## Review Conversations Are Author-Owned
|
||||
|
||||
If a review bot leaves review conversations on your PR, you are expected to handle the follow-through:
|
||||
|
||||
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
|
||||
- Reply and leave it open only when you need maintainer or reviewer judgment
|
||||
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
|
||||
|
||||
This applies to both human-authored and AI-assisted PRs.
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
|
||||
@@ -101,8 +124,9 @@ Please include in your PR:
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] Resolve or reply to bot review conversations after you address them
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
|
||||
91
Dockerfile
91
Dockerfile
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Opt-in extension dependencies at build time (space-separated directory names).
|
||||
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
|
||||
#
|
||||
@@ -48,16 +50,25 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
COPY --from=ext-deps /out/ ./extensions/
|
||||
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# Normalize extension paths now so runtime COPY preserves safe modes
|
||||
# without adding a second full extensions layer.
|
||||
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
|
||||
if [ -d "$dir" ]; then \
|
||||
find "$dir" -type d -exec chmod 755 {} +; \
|
||||
find "$dir" -type f -exec chmod 644 {} +; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
|
||||
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
|
||||
# Stub it so local cross-arch builds still succeed.
|
||||
@@ -67,11 +78,17 @@ RUN pnpm canvas:a2ui:bundle || \
|
||||
echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > src/canvas-host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN pnpm build
|
||||
RUN pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
RUN CI=true pnpm prune --prod && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
|
||||
|
||||
# ── Runtime base images ─────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
|
||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
|
||||
@@ -102,36 +119,39 @@ WORKDIR /app
|
||||
|
||||
# Install system utilities present in bookworm but missing in bookworm-slim.
|
||||
# On the full bookworm image these are already installed (apt-get is a no-op).
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git openssl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
procps hostname curl git openssl
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/package.json .
|
||||
COPY --from=build --chown=node:node /app/openclaw.mjs .
|
||||
COPY --from=build --chown=node:node /app/extensions ./extensions
|
||||
COPY --from=build --chown=node:node /app/skills ./skills
|
||||
COPY --from=build --chown=node:node /app/docs ./docs
|
||||
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
|
||||
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=runtime-assets --chown=node:node /app/package.json .
|
||||
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
|
||||
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/docs ./docs
|
||||
|
||||
# Docker live-test runners invoke `pnpm` inside the runtime image.
|
||||
# Activate the exact pinned package manager now so the container does not
|
||||
# rely on a first-run network fetch or missing shims under the non-root user.
|
||||
RUN corepack enable && \
|
||||
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate
|
||||
# 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
|
||||
# first-run network fetch when invoking pnpm.
|
||||
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||
RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
corepack enable && \
|
||||
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \
|
||||
chmod -R a+rX "$COREPACK_HOME"
|
||||
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
@@ -139,15 +159,15 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after node_modules COPY so playwright-core is available.
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
|
||||
mkdir -p /home/node/.cache/ms-playwright && \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
|
||||
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
|
||||
chown -R node:node /home/node/.cache/ms-playwright && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
chown -R node:node /home/node/.cache/ms-playwright; \
|
||||
fi
|
||||
|
||||
# Optionally install Docker CLI for sandbox container management.
|
||||
@@ -156,7 +176,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
# Required for agents.defaults.sandbox to function in Docker deployments.
|
||||
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
|
||||
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg && \
|
||||
@@ -177,20 +199,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
docker-ce-cli docker-compose-plugin && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
docker-ce-cli docker-compose-plugin; \
|
||||
fi
|
||||
|
||||
# Normalize extension paths so plugin safety checks do not reject
|
||||
# world-writable directories inherited from source file modes.
|
||||
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
|
||||
if [ -d "$dir" ]; then \
|
||||
find "$dir" -type d -exec chmod 755 {} +; \
|
||||
find "$dir" -type f -exec chmod 644 {} +; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -10,8 +14,7 @@ RUN apt-get update \
|
||||
git \
|
||||
jq \
|
||||
python3 \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ripgrep
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -17,11 +21,9 @@ RUN apt-get update \
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
xvfb
|
||||
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
|
||||
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -19,9 +21,10 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
|
||||
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
|
||||
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
@@ -42,4 +45,3 @@ fi
|
||||
|
||||
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
|
||||
USER ${FINAL_USER}
|
||||
|
||||
|
||||
213
appcast.xml
213
appcast.xml
@@ -2,6 +2,80 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.8-beta.1</title>
|
||||
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030801</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.8-beta.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.8-beta.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI/backup: add <code>openclaw backup create</code> and <code>openclaw backup verify</code> for local state archives, including <code>--only-config</code>, <code>--no-include-workspace</code>, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.</li>
|
||||
<li>macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext <code>gateway.remote.token</code> config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.</li>
|
||||
<li>Talk mode: add top-level <code>talk.silenceTimeoutMs</code> config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.</li>
|
||||
<li>TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit <code>agent:</code> session targets. (#39591) thanks @arceus77-7.</li>
|
||||
<li>Tools/Brave web search: add opt-in <code>tools.web.search.brave.mode: "llm-context"</code> so <code>web_search</code> can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.</li>
|
||||
<li>CLI/install: include the short git commit hash in <code>openclaw --version</code> output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.</li>
|
||||
<li>CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.</li>
|
||||
<li>ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (<code>openclaw acp --provenance off|meta|meta+receipt</code>) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.</li>
|
||||
<li>Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.</li>
|
||||
<li>Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.</li>
|
||||
<li>Extensions/ACPX tests: move the shared runtime fixture helper from <code>src/runtime-internals/</code> to <code>src/test-utils/</code> so the test-only helper no longer looks like shipped runtime code.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.</li>
|
||||
<li>Android/Play distribution: remove self-update, background location, <code>screen.record</code>, and background mic capture from the Android app, narrow the foreground service to <code>dataSync</code> only, and clean up the legacy <code>location.enabledMode=always</code> preference migration. (#39660) Thanks @obviyus.</li>
|
||||
<li>Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both <code>agent:main:main</code> and <code>agent:main:telegram:direct:<id></code> resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.</li>
|
||||
<li>Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report <code>delivered: true</code> when no message actually reached Telegram. (#40575) thanks @obviyus.</li>
|
||||
<li>Matrix/DM routing: add safer fallback detection for broken <code>m.direct</code> homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.</li>
|
||||
<li>Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.</li>
|
||||
<li>Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)</li>
|
||||
<li>Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.</li>
|
||||
<li>Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.</li>
|
||||
<li>Browser/extension relay: add <code>browser.relayBindHost</code> so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.</li>
|
||||
<li>Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for <code>/json/*</code> tab operations so local <code>ws://</code> / <code>wss://</code> profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.</li>
|
||||
<li>Browser/CDP: rewrite wildcard <code>ws://0.0.0.0</code> and <code>ws://[::]</code> debugger URLs from remote <code>/json/version</code> responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.</li>
|
||||
<li>Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with <code>tab not found</code>, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.</li>
|
||||
<li>macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved <code>.ts.net</code> and Tailscale Serve gateways, and set <code>TERM=dumb</code> for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.</li>
|
||||
<li>TUI/theme: detect light terminal backgrounds via <code>COLORFGBG</code> and pick a WCAG AA-compliant light palette, with <code>OPENCLAW_THEME=light|dark</code> override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.</li>
|
||||
<li>Agents/openai-codex: normalize <code>gpt-5.4</code> fallback transport back to <code>openai-codex-responses</code> on <code>chatgpt.com/backend-api</code> when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.</li>
|
||||
<li>Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for <code>openai-codex/gpt-5.4</code> instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.</li>
|
||||
<li>Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy <code>OPENROUTER_API_KEY</code>, <code>sk-or-...</code>, and explicit <code>perplexity.baseUrl</code> / <code>model</code> setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.</li>
|
||||
<li>Agents/failover: detect Amazon Bedrock <code>Too many tokens per day</code> quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window <code>too many tokens per request</code> errors out of the rate-limit lane. (#39377) Thanks @gambletan.</li>
|
||||
<li>Mattermost replies: keep <code>root_id</code> pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.</li>
|
||||
<li>Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.</li>
|
||||
<li>macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared <code>inout</code> visibility mutation from <code>OverlayPanelFactory.present</code>, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.</li>
|
||||
<li>macOS Talk Mode: set the speech recognition request <code>taskHint</code> to <code>.dictation</code> for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.</li>
|
||||
<li>macOS release packaging: default <code>scripts/package-mac-app.sh</code> to universal binaries for <code>BUILD_CONFIG=release</code>, and clarify that <code>scripts/package-mac-dist.sh</code> already produces the release zip + DMG. (#33891) Thanks @cgdusek.</li>
|
||||
<li>Hooks/session-memory: keep <code>/new</code> and <code>/reset</code> memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.</li>
|
||||
<li>Sessions/model switch: clear stale cached <code>contextTokens</code> when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.</li>
|
||||
<li>ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.</li>
|
||||
<li>Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.</li>
|
||||
<li>Context engine registry/bundled builds: share the registry state through a <code>globalThis</code> singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.</li>
|
||||
<li>Podman/setup: fix <code>cannot chdir: Permission denied</code> in <code>run_as_user</code> when <code>setup-podman.sh</code> is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to <code>/tmp</code> with <code>/</code> fallback. (#39435) Thanks @langdon and @jlcbk.</li>
|
||||
<li>Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add <code>:Z</code> relabel to bind mounts in <code>run-openclaw-podman.sh</code> and the Quadlet template, fixing <code>EACCES</code> on Fedora/RHEL hosts. Supports <code>OPENCLAW_BIND_MOUNT_OPTIONS</code> override. (#39449) Thanks @langdon and @githubbzxs.</li>
|
||||
<li>Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)</li>
|
||||
<li>Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.</li>
|
||||
<li>Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.</li>
|
||||
<li>Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.</li>
|
||||
<li>Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.</li>
|
||||
<li>Gateway/launchd respawn detection: treat <code>XPC_SERVICE_NAME</code> as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.</li>
|
||||
<li>Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale <code>getUpdates</code> long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.</li>
|
||||
<li>Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.</li>
|
||||
<li>Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so <code>cron</code>/<code>gateway</code> tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.</li>
|
||||
<li>Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.</li>
|
||||
<li>MS Teams/authz: keep <code>groupPolicy: "allowlist"</code> enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.</li>
|
||||
<li>Security/system.run: bind approved <code>bun</code> and <code>deno run</code> script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.</li>
|
||||
<li>Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.7</title>
|
||||
<pubDate>Sun, 08 Mar 2026 04:42:35 +0000</pubDate>
|
||||
@@ -584,144 +658,5 @@
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
||||
<!-- pragma: allowlist secret -->
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.1</title>
|
||||
<pubDate>Mon, 02 Mar 2026 04:40:59 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Agents/Thinking defaults: set <code>adaptive</code> as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at <code>low</code> unless explicitly configured.</li>
|
||||
<li>Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (<code>/health</code>, <code>/healthz</code>, <code>/ready</code>, <code>/readyz</code>) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.</li>
|
||||
<li>Android/Nodes: add <code>camera.list</code>, <code>device.permissions</code>, <code>device.health</code>, and <code>notifications.actions</code> (<code>open</code>/<code>dismiss</code>/<code>reply</code>) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.</li>
|
||||
<li>Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (<code>idleHours</code>, default 24h) plus optional hard <code>maxAgeHours</code> lifecycle controls, and add <code>/session idle</code> + <code>/session max-age</code> commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.</li>
|
||||
<li>Telegram/DM topics: add per-DM <code>direct</code> + topic config (allowlists, <code>dmPolicy</code>, <code>skills</code>, <code>systemPrompt</code>, <code>requireTopic</code>), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.</li>
|
||||
<li>Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.</li>
|
||||
<li>OpenAI/Streaming transport: make <code>openai</code> Responses WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (<code>store</code> + <code>context_management</code>) on the WS path.</li>
|
||||
<li>Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes parity: add <code>system.notify</code>, <code>photos.latest</code>, <code>contacts.search</code>/<code>contacts.add</code>, <code>calendar.events</code>/<code>calendar.add</code>, and <code>motion.activity</code>/<code>motion.pedometer</code>, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.</li>
|
||||
<li>CLI/Config: add <code>openclaw config file</code> to print the active config file path resolved from <code>OPENCLAW_CONFIG_PATH</code> or the default location. (#26256) thanks @cyb1278588254.</li>
|
||||
<li>Feishu/Docx tables + uploads: add <code>feishu_doc</code> actions for Docx table creation/cell writing (<code>create_table</code>, <code>write_table_cells</code>, <code>create_table_with_values</code>) and image/file uploads (<code>upload_image</code>, <code>upload_file</code>) with stricter create/upload error handling for missing <code>document_id</code> and placeholder cleanup failures. (#20304) Thanks @xuhao1.</li>
|
||||
<li>Feishu/Reactions: add inbound <code>im.message.reaction.created_v1</code> handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.</li>
|
||||
<li>Feishu/Chat tooling: add <code>feishu_chat</code> tool actions for chat info and member queries, with configurable enablement under <code>channels.feishu.tools.chat</code>. (#14674) Thanks @liuweifly.</li>
|
||||
<li>Feishu/Doc permissions: support optional owner permission grant fields on <code>feishu_doc</code> create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.</li>
|
||||
<li>Web UI/i18n: add German (<code>de</code>) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.</li>
|
||||
<li>Tools/Diffs: add a new optional <code>diffs</code> plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.</li>
|
||||
<li>Memory/LanceDB: support custom OpenAI <code>baseUrl</code> and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.</li>
|
||||
<li>ACP/ACPX streaming: pin ACPX plugin support to <code>0.1.15</code>, add configurable ACPX command/version probing, and streamline ACP stream delivery (<code>final_only</code> default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.</li>
|
||||
<li>Shell env markers: set <code>OPENCLAW_SHELL</code> across shell-like runtimes (<code>exec</code>, <code>acp</code>, <code>acp-client</code>, <code>tui-local</code>) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.</li>
|
||||
<li>Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (<code>--light-context</code> for cron agent turns and <code>agents.*.heartbeat.lightContext</code> for heartbeat), keeping only <code>HEARTBEAT.md</code> for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.</li>
|
||||
<li>OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (<code>response.create</code> with <code>generate:false</code>), enable it by default for <code>openai/*</code>, and expose <code>params.openaiWsWarmup</code> for per-model enable/disable control.</li>
|
||||
<li>Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (<code>task_completion</code>) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured <code>internalEvents</code>.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Node exec approval payloads now require <code>systemRunPlan</code>. <code>host=node</code> approval requests without that plan are rejected.</li>
|
||||
<li><strong>BREAKING:</strong> Node <code>system.run</code> execution now pins path-token commands to the canonical executable path (<code>realpath</code>) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example <code>tr</code>) must now accept canonical paths (for example <code>/usr/bin/tr</code>).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Android/Nodes reliability: reject <code>facing=both</code> when <code>deviceId</code> is set to avoid mislabeled duplicate captures, allow notification <code>open</code>/<code>reply</code> on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.</li>
|
||||
<li>Windows/Plugin install: avoid <code>spawn EINVAL</code> on Windows npm/npx invocations by resolving to <code>node</code> + npm CLI scripts instead of spawning <code>.cmd</code> directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.</li>
|
||||
<li>LINE/Voice transcription: classify M4A voice media as <code>audio/mp4</code> (not <code>video/mp4</code>) by checking the MPEG-4 <code>ftyp</code> major brand (<code>M4A </code> / <code>M4B </code>), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct <code>accountId</code> instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.</li>
|
||||
<li>Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.</li>
|
||||
<li>Android/Photos permissions: declare Android 14+ selected-photo access permission (<code>READ_MEDIA_VISUAL_USER_SELECTED</code>) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.</li>
|
||||
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
|
||||
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
|
||||
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
|
||||
<li>Feishu/Reply media attachments: send Feishu reply <code>mediaUrl</code>/<code>mediaUrls</code> payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when <code>mediaUrls</code> is empty. (#28959) Thanks @icesword0760.</li>
|
||||
<li>Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (<code>SLACK_USER_TOKEN</code> env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Outbound session routing: stop assuming bare <code>oc_</code> identifiers are always group chats, honor explicit <code>dm:</code>/<code>group:</code> prefixes for <code>oc_</code> chat IDs, and default ambiguous bare <code>oc_</code> targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.</li>
|
||||
<li>Feishu/Group session routing: add configurable group session scopes (<code>group</code>, <code>group_sender</code>, <code>group_topic</code>, <code>group_topic_sender</code>) with legacy <code>topicSessionMode=enabled</code> compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.</li>
|
||||
<li>Feishu/Reply-in-thread routing: add <code>replyInThread</code> config (<code>disabled|enabled</code>) for group replies, propagate <code>reply_in_thread</code> across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.</li>
|
||||
<li>Feishu/Probe status caching: cache successful <code>probeFeishu()</code> bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Opus media send type: send <code>.opus</code> attachments with <code>msg_type: "audio"</code> (instead of <code>"media"</code>) so Feishu voice messages deliver correctly while <code>.mp4</code> remains <code>msg_type: "media"</code> and documents remain <code>msg_type: "file"</code>. (#28269) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Mobile video media type: treat inbound <code>message_type: "media"</code> as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.</li>
|
||||
<li>Feishu/Inbound sender fallback: fall back to <code>sender_id.user_id</code> when <code>sender_id.open_id</code> is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.</li>
|
||||
<li>Feishu/Reply context metadata: include inbound <code>parent_id</code> and <code>root_id</code> as <code>ReplyToId</code>/<code>RootMessageId</code> in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.</li>
|
||||
<li>Feishu/Post embedded media: extract <code>media</code> tags from inbound rich-text (<code>post</code>) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.</li>
|
||||
<li>Feishu/Local media sends: propagate <code>mediaLocalRoots</code> through Feishu outbound media sending into <code>loadWebMedia</code> so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.</li>
|
||||
<li>Feishu/Group wildcard policy fallback: honor <code>channels.feishu.groups["*"]</code> when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.</li>
|
||||
<li>Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (<code>image</code> stays <code>image</code>, non-image maps to <code>file</code>) to prevent reintroducing unsupported Feishu <code>type=audio</code> fetches. (#16311, #8746) Thanks @Yaxuan42.</li>
|
||||
<li>TTS/Voice bubbles: use opus output and enable <code>audioAsVoice</code> routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.</li>
|
||||
<li>Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes notification wake flow: enable Android <code>system.notify</code> default allowlist, emit <code>notifications.changed</code> events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.</li>
|
||||
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
|
||||
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
|
||||
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
|
||||
<li>Feishu/Inbound rich-text parsing: preserve <code>share_chat</code> payload summaries when available and add explicit parsing for rich-text <code>code</code>/<code>code_block</code>/<code>pre</code> tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.</li>
|
||||
<li>Feishu/Post markdown parsing: parse rich-text <code>post</code> payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.</li>
|
||||
<li>Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.</li>
|
||||
<li>Slack/Native commands: register Slack native status as <code>/agentstatus</code> (Slack-reserved <code>/status</code>) so manifest slash command registration stays valid while text <code>/status</code> still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.</li>
|
||||
<li>Android/Camera clip: remove <code>camera.clip</code> HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive <code>maxWidth</code> values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.</li>
|
||||
<li>Android/Gateway canvas capability refresh: send <code>node.canvas.capability.refresh</code> with object <code>params</code> (<code>{}</code>) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.</li>
|
||||
<li>Gateway/Control UI origins: honor <code>gateway.controlUi.allowedOrigins: ["*"]</code> wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.</li>
|
||||
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
|
||||
<li>Agents/Sessions list transcript paths: handle missing/non-string/relative <code>sessions.list.path</code> values and per-agent <code>{agentId}</code> templates when deriving <code>transcriptPath</code>, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.</li>
|
||||
<li>Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.</li>
|
||||
<li>CLI/Install: add an npm-link fallback to fix CLI startup <code>Permission denied</code> failures (<code>exit 127</code>) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.</li>
|
||||
<li>Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.</li>
|
||||
<li>Plugins/NPM spec install: fix npm-spec plugin installs when <code>npm pack</code> output is empty by detecting newly created <code>.tgz</code> archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.</li>
|
||||
<li>Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.</li>
|
||||
<li>Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.</li>
|
||||
<li>Gateway/macOS supervised restart: actively <code>launchctl kickstart -k</code> during intentional supervised restarts to bypass LaunchAgent <code>ThrottleInterval</code> delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.</li>
|
||||
<li>Daemon/macOS TLS certs: default LaunchAgent service env <code>NODE_EXTRA_CA_CERTS</code> to <code>/etc/ssl/cert.pem</code> (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.</li>
|
||||
<li>Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Reaction notifications: add <code>channels.feishu.reactionNotifications</code> (<code>off | own | all</code>, default <code>own</code>) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.</li>
|
||||
<li>Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (<code>429</code>, <code>99991400</code>, <code>99991403</code>) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.</li>
|
||||
<li>Feishu/Zalo runtime logging: replace direct <code>console.log/error</code> usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.</li>
|
||||
<li>Feishu/Group sender allowlist fallback: add global <code>channels.feishu.groupSenderAllowFrom</code> sender authorization for group chats, with per-group <code>groups.<id>.allowFrom</code> precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.</li>
|
||||
<li>Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.</li>
|
||||
<li>Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when <code>document.convert</code> hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.</li>
|
||||
<li>Feishu/API quota controls: add <code>typingIndicator</code> and <code>resolveSenderNames</code> config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.</li>
|
||||
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
|
||||
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</li>
|
||||
<li>Sessions/Internal routing: preserve established external <code>lastTo</code>/<code>lastChannel</code> routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.</li>
|
||||
<li>Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.</li>
|
||||
<li>Auto-reply/NO_REPLY: strip <code>NO_REPLY</code> token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.</li>
|
||||
<li>Update/Global npm: fallback to <code>--omit=optional</code> when global <code>npm update</code> fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.</li>
|
||||
<li>Inbound metadata/Multi-account routing: include <code>account_id</code> in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.</li>
|
||||
<li>Model directives/Auth profiles: split <code>/model</code> profile suffixes at the first <code>@</code> after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.</li>
|
||||
<li>Cron/Delivery mode none: send explicit <code>delivery: { mode: "none" }</code> from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.</li>
|
||||
<li>Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.</li>
|
||||
<li>Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with <code>think=off</code> to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.</li>
|
||||
<li>Ollama/Embedded runner base URL precedence: prioritize configured provider <code>baseUrl</code> over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.</li>
|
||||
<li>Agents/Failover reason classification: avoid false rate-limit classification from incidental <code>tpm</code> substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.</li>
|
||||
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
|
||||
<li>Gateway/WS: close repeated post-handshake <code>unauthorized role:*</code> request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.</li>
|
||||
<li>Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.</li>
|
||||
<li>CLI/Ollama config: allow <code>config set</code> for Ollama <code>apiKey</code> without predeclared provider config. (#29299) Thanks @vincentkoc.</li>
|
||||
<li>Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.</li>
|
||||
<li>Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.</li>
|
||||
<li>Agents/Ollama: demote empty-discovery logging from <code>warn</code> to <code>debug</code> to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.</li>
|
||||
<li>fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.</li>
|
||||
<li>Docker/Image permissions: normalize <code>/app/extensions</code>, <code>/app/.agent</code>, and <code>/app/.agents</code> to directory mode <code>755</code> and file mode <code>644</code> during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.</li>
|
||||
<li>OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty <code>baseUrl</code> as non-direct, honor <code>compat.supportsStore=false</code>, and auto-inject server-side compaction <code>context_management</code> for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.</li>
|
||||
<li>Sandbox/Browser Docker: pass <code>OPENCLAW_BROWSER_NO_SANDBOX=1</code> to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.</li>
|
||||
<li>Usage normalization: clamp negative prompt/input token values to zero (including <code>prompt_tokens</code> alias inputs) so <code>/usage</code> and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Secrets/Auth profiles: normalize inline SecretRef <code>token</code>/<code>key</code> values to canonical <code>tokenRef</code>/<code>keyRef</code> before persistence, and keep explicit <code>keyRef</code> precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.</li>
|
||||
<li>Tools/Edit workspace boundary errors: preserve the real <code>Path escapes workspace root</code> failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.</li>
|
||||
<li>Browser/Open & navigate: accept <code>url</code> as an alias parameter for <code>open</code> and <code>navigate</code>. (#29260) Thanks @vincentkoc.</li>
|
||||
<li>Codex/Usage window: label weekly usage window as <code>Week</code> instead of <code>Day</code>. (#26267) Thanks @Sid-Qin.</li>
|
||||
<li>Signal/Sync message null-handling: treat <code>syncMessage</code> presence (including <code>null</code>) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.</li>
|
||||
<li>Infra/fs-safe: sanitize directory-read failures so raw <code>EISDIR</code> text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.</li>
|
||||
<li>Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false <code>cannot create directories</code> failures in sandbox write mode. (#30610) Thanks @glitch418x.</li>
|
||||
<li>Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.</li>
|
||||
<li>Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (<code>198.18.0.0/15</code>) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.</li>
|
||||
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
|
||||
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
|
||||
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
|
||||
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
|
||||
<!-- pragma: allowlist secret -->
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -63,8 +63,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603081
|
||||
versionName = "2026.3.8"
|
||||
versionCode = 202603090
|
||||
versionName = "2026.3.9"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
internal object TalkDefaults {
|
||||
const val defaultSilenceTimeoutMs = 700L
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.normalizeMainKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
internal data class TalkModeGatewayConfigState(
|
||||
val activeProvider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val missingResolvedPayload: Boolean,
|
||||
val mainSessionKey: String,
|
||||
val defaultVoiceId: String?,
|
||||
val voiceAliases: Map<String, String>,
|
||||
val defaultModelId: String,
|
||||
val defaultOutputFormat: String,
|
||||
val apiKey: String?,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
)
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
|
||||
fun parse(
|
||||
config: JsonObject?,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState {
|
||||
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 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(
|
||||
activeProvider = activeProvider,
|
||||
normalizedPayload = selection?.normalizedPayload == true,
|
||||
missingResolvedPayload = talk != null && selection == null,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
|
||||
val fallback = TalkDefaults.defaultSilenceTimeoutMs
|
||||
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
|
||||
if (primitive.isString) return fallback
|
||||
val timeout = primitive.content.toDoubleOrNull() ?: return fallback
|
||||
if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) {
|
||||
return fallback
|
||||
}
|
||||
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? =
|
||||
this?.let { element ->
|
||||
element as? JsonPrimitive
|
||||
}?.contentOrNull
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return primitive.booleanOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? =
|
||||
this as? JsonObject
|
||||
@@ -59,52 +59,11 @@ class TalkModeManager(
|
||||
private const val tag = "TalkMode"
|
||||
private const val defaultModelIdFallback = "eleven_v3"
|
||||
private const val defaultOutputFormatFallback = "pcm_24000"
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
private const val silenceWindowMs = 500L
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
private const val listenWatchdogMs = 12_000L
|
||||
private const val chatFinalWaitWithSubscribeMs = 45_000L
|
||||
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
|
||||
private const val maxCachedRunCompletions = 128
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
||||
return trimmed.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
||||
if (talk == null) return null
|
||||
val rawProvider = talk["provider"].asStringOrNull()
|
||||
val rawProviders = talk["providers"].asObjectOrNull()
|
||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
||||
if (hasNormalizedPayload) {
|
||||
val providers =
|
||||
rawProviders?.entries?.mapNotNull { (key, value) ->
|
||||
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
|
||||
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
|
||||
providerId to providerConfig
|
||||
}?.toMap().orEmpty()
|
||||
val providerId =
|
||||
normalizeTalkProviderId(rawProvider)
|
||||
?: providers.keys.sorted().firstOrNull()
|
||||
?: defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider = providerId,
|
||||
config = providers[providerId] ?: buildJsonObject {},
|
||||
normalizedPayload = true,
|
||||
)
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider = defaultTalkProvider,
|
||||
config = talk,
|
||||
normalizedPayload = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -134,7 +93,7 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
private var listeningMode = false
|
||||
|
||||
private var silenceJob: Job? = null
|
||||
private val silenceWindowMs = 700L
|
||||
private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
|
||||
private var lastTranscript: String = ""
|
||||
private var lastHeardAtMs: Long? = null
|
||||
private var lastSpokenText: String? = null
|
||||
@@ -854,7 +813,7 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
_lastAssistantText.value = cleaned
|
||||
|
||||
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val resolvedVoice = resolveVoiceAlias(requestedVoice)
|
||||
val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases)
|
||||
if (requestedVoice != null && resolvedVoice == null) {
|
||||
Log.w(tag, "unknown voice alias: $requestedVoice")
|
||||
}
|
||||
@@ -877,12 +836,35 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
apiKey?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId
|
||||
val voiceId =
|
||||
val resolvedPlaybackVoice =
|
||||
if (!apiKey.isNullOrEmpty()) {
|
||||
resolveVoiceId(preferredVoice, apiKey)
|
||||
try {
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = preferredVoice,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
voiceOverrideActive = voiceOverrideActive,
|
||||
listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) },
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
resolvedPlaybackVoice?.let { resolved ->
|
||||
fallbackVoiceId = resolved.fallbackVoiceId
|
||||
defaultVoiceId = resolved.defaultVoiceId
|
||||
currentVoiceId = resolved.currentVoiceId
|
||||
resolved.selectedVoiceName?.let { name ->
|
||||
resolved.voiceId?.let { voiceId ->
|
||||
Log.d(tag, "default voice selected $name ($voiceId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
val voiceId = resolvedPlaybackVoice?.voiceId
|
||||
|
||||
_statusText.value = "Speaking…"
|
||||
_isSpeaking.value = true
|
||||
@@ -1393,60 +1375,64 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
try {
|
||||
val res = session.request("talk.config", """{"includeSecrets":true}""")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val selection = selectTalkProviderConfig(talk)
|
||||
val activeProvider = selection?.provider ?: defaultTalkProvider
|
||||
val activeConfig = selection?.config
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = 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
|
||||
normalizeAliasKey(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 parsed =
|
||||
TalkModeGatewayConfigParser.parse(
|
||||
config = root?.get("config").asObjectOrNull(),
|
||||
defaultProvider = defaultTalkProvider,
|
||||
defaultModelIdFallback = defaultModelIdFallback,
|
||||
defaultOutputFormatFallback = defaultOutputFormatFallback,
|
||||
envVoice = envVoice,
|
||||
sagVoice = sagVoice,
|
||||
envKey = envKey,
|
||||
)
|
||||
if (parsed.missingResolvedPayload) {
|
||||
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
|
||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
||||
mainSessionKey = mainKey
|
||||
mainSessionKey = parsed.mainSessionKey
|
||||
}
|
||||
defaultVoiceId =
|
||||
if (activeProvider == defaultTalkProvider) {
|
||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
voice
|
||||
}
|
||||
voiceAliases = aliases
|
||||
defaultVoiceId = parsed.defaultVoiceId
|
||||
voiceAliases = parsed.voiceAliases
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
defaultModelId = model ?: defaultModelIdFallback
|
||||
defaultModelId = parsed.defaultModelId
|
||||
if (!modelOverrideActive) currentModelId = defaultModelId
|
||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
|
||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
|
||||
Log.d(tag, "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId")
|
||||
if (interrupt != null) interruptOnSpeech = interrupt
|
||||
activeProviderIsElevenLabs = activeProvider == defaultTalkProvider
|
||||
defaultOutputFormat = parsed.defaultOutputFormat
|
||||
apiKey = parsed.apiKey
|
||||
silenceWindowMs = parsed.silenceTimeoutMs
|
||||
Log.d(
|
||||
tag,
|
||||
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}",
|
||||
)
|
||||
if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech
|
||||
activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider
|
||||
if (!activeProviderIsElevenLabs) {
|
||||
// Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls
|
||||
apiKey = null
|
||||
defaultVoiceId = null
|
||||
if (!voiceOverrideActive) currentVoiceId = null
|
||||
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
|
||||
} else if (selection?.normalizedPayload == true) {
|
||||
Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback")
|
||||
} else if (parsed.normalizedPayload) {
|
||||
Log.d(tag, "talk config provider=elevenlabs")
|
||||
}
|
||||
configLoaded = true
|
||||
} catch (_: Throwable) {
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
defaultModelId = defaultModelIdFallback
|
||||
val fallback =
|
||||
TalkModeGatewayConfigParser.fallback(
|
||||
defaultProvider = defaultTalkProvider,
|
||||
defaultModelIdFallback = defaultModelIdFallback,
|
||||
defaultOutputFormatFallback = defaultOutputFormatFallback,
|
||||
envVoice = envVoice,
|
||||
sagVoice = sagVoice,
|
||||
envKey = envKey,
|
||||
)
|
||||
silenceWindowMs = fallback.silenceTimeoutMs
|
||||
defaultVoiceId = fallback.defaultVoiceId
|
||||
defaultModelId = fallback.defaultModelId
|
||||
if (!modelOverrideActive) currentModelId = defaultModelId
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = emptyMap()
|
||||
defaultOutputFormat = defaultOutputFormatFallback
|
||||
apiKey = fallback.apiKey
|
||||
voiceAliases = fallback.voiceAliases
|
||||
defaultOutputFormat = fallback.defaultOutputFormat
|
||||
// Keep config load retryable after transient fetch failures.
|
||||
configLoaded = false
|
||||
}
|
||||
@@ -1740,82 +1726,6 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveVoiceAlias(value: 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
|
||||
}
|
||||
|
||||
private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? {
|
||||
val trimmed = preferred?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
val resolved = resolveVoiceAlias(trimmed)
|
||||
// If it resolves as an alias, use the alias target.
|
||||
// Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM").
|
||||
return resolved ?: trimmed
|
||||
}
|
||||
fallbackVoiceId?.let { return it }
|
||||
|
||||
return try {
|
||||
val voices = listVoices(apiKey)
|
||||
val first = voices.firstOrNull() ?: return null
|
||||
fallbackVoiceId = first.voiceId
|
||||
if (defaultVoiceId.isNullOrBlank()) {
|
||||
defaultVoiceId = first.voiceId
|
||||
}
|
||||
if (!voiceOverrideActive) {
|
||||
currentVoiceId = first.voiceId
|
||||
}
|
||||
val name = first.name ?: "unknown"
|
||||
Log.d(tag, "default voice selected $name (${first.voiceId})")
|
||||
first.voiceId
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun listVoices(apiKey: String): List<ElevenLabsVoice> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
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.readBytes()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
||||
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
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.readBytes()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,100 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,36 @@ import org.junit.Test
|
||||
class TalkModeConfigParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun prefersCanonicalResolvedTalkProviderPayload() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"resolved": {
|
||||
"provider": "elevenlabs",
|
||||
"config": {
|
||||
"voiceId": "voice-resolved"
|
||||
}
|
||||
},
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefersNormalizedTalkProviderPayload() {
|
||||
val talk =
|
||||
@@ -31,11 +61,52 @@ class TalkModeConfigParsingTest {
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
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
|
||||
@@ -47,11 +118,46 @@ class TalkModeConfigParsingTest {
|
||||
put("apiKey", legacyApiKey) // pragma: allowlist secret
|
||||
}
|
||||
|
||||
val selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
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
|
||||
fun defaultsSilenceTimeoutMsWhenMissing() {
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsSilenceTimeoutMsWhenInvalid() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", 0) }
|
||||
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsSilenceTimeoutMsWhenString() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", "1500") }
|
||||
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<key>NSExtension</key>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<key>NSExtension</key>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
enum A2UIReadyState {
|
||||
case ready(String)
|
||||
case hostNotConfigured
|
||||
case hostUnavailable
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func resolveCanvasHostURL() async -> String? {
|
||||
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
if let host = base.host, LoopbackHost.isLoopback(host) {
|
||||
return nil
|
||||
}
|
||||
return base.appendingPathComponent("__openclaw__/canvas/").absoluteString
|
||||
}
|
||||
|
||||
func _test_resolveA2UIHostURL() async -> String? {
|
||||
await self.resolveA2UIHostURL()
|
||||
}
|
||||
@@ -19,22 +34,14 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
await MainActor.run {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
return
|
||||
}
|
||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
|
||||
// "could not connect to the server" overlay even when the gateway is connected.
|
||||
if let url = URL(string: a2uiUrl),
|
||||
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
|
||||
let url = URL(string: canvasUrl),
|
||||
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
|
||||
{
|
||||
self.screen.navigate(to: a2uiUrl)
|
||||
self.lastAutoA2uiURL = a2uiUrl
|
||||
self.screen.navigate(to: canvasUrl)
|
||||
self.lastAutoA2uiURL = canvasUrl
|
||||
} else {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
@@ -42,11 +49,46 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState {
|
||||
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
|
||||
return .hostNotConfigured
|
||||
}
|
||||
self.screen.navigate(to: initialUrl)
|
||||
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
|
||||
return .ready(initialUrl)
|
||||
}
|
||||
|
||||
// First render can fail when scoped capability rotates between reconnects.
|
||||
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
|
||||
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
|
||||
self.screen.navigate(to: refreshedUrl)
|
||||
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
|
||||
return .ready(refreshedUrl)
|
||||
}
|
||||
return .hostUnavailable
|
||||
}
|
||||
|
||||
func showLocalCanvasOnDisconnect() {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
|
||||
private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? {
|
||||
if let url = await self.resolveA2UIHostURL() {
|
||||
return url
|
||||
}
|
||||
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
|
||||
return await self.resolveA2UIHostURL()
|
||||
}
|
||||
|
||||
private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? {
|
||||
if let url = await self.resolveCanvasHostURL() {
|
||||
return url
|
||||
}
|
||||
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
|
||||
return await self.resolveCanvasHostURL()
|
||||
}
|
||||
|
||||
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||
guard let host = url.host, !host.isEmpty else { return false }
|
||||
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||
|
||||
@@ -57,6 +57,7 @@ final class NodeAppModel {
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
enum CameraHUDKind {
|
||||
@@ -129,8 +130,8 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectSuppressed = false
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenWatchReplyIds = Set<String>()
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
private var pendingForegroundActionDrainInFlight = false
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
@@ -330,6 +331,9 @@ final class NodeAppModel {
|
||||
}
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
||||
}
|
||||
Task { [weak self] in
|
||||
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
|
||||
}
|
||||
}
|
||||
if phase == .active, self.reconnectAfterBackgroundArmed {
|
||||
self.reconnectAfterBackgroundArmed = false
|
||||
@@ -358,7 +362,14 @@ final class NodeAppModel {
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.gatewayConnected = false
|
||||
// Foreground recovery must actively restart the saved gateway config.
|
||||
// Disconnecting stale sockets alone can leave us idle if the old
|
||||
// reconnect tasks were suppressed or otherwise got stuck in background.
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
if let cfg = self.activeGatewayConnectConfig {
|
||||
self.applyGatewayConnectConfig(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,16 +889,17 @@ final class NodeAppModel {
|
||||
let command = req.command
|
||||
switch command {
|
||||
case OpenClawCanvasA2UICommand.reset.rawValue:
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
|
||||
case .ready:
|
||||
break
|
||||
case .hostNotConfigured:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .unavailable,
|
||||
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
||||
}
|
||||
self.screen.navigate(to: a2uiUrl)
|
||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||
case .hostUnavailable:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
@@ -895,7 +907,6 @@ final class NodeAppModel {
|
||||
code: .unavailable,
|
||||
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
|
||||
}
|
||||
|
||||
let json = try await self.screen.eval(javaScript: """
|
||||
(() => {
|
||||
const host = globalThis.openclawA2UI;
|
||||
@@ -904,6 +915,7 @@ final class NodeAppModel {
|
||||
})()
|
||||
""")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
|
||||
case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
|
||||
let messages: [OpenClawKit.AnyCodable]
|
||||
if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
|
||||
@@ -920,16 +932,17 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
|
||||
case .ready:
|
||||
break
|
||||
case .hostNotConfigured:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .unavailable,
|
||||
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
||||
}
|
||||
self.screen.navigate(to: a2uiUrl)
|
||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||
case .hostUnavailable:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
@@ -2099,6 +2112,22 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private struct PendingForegroundNodeAction: Decodable {
|
||||
var id: String
|
||||
var command: String
|
||||
var paramsJSON: String?
|
||||
var enqueuedAtMs: Int?
|
||||
}
|
||||
|
||||
private struct PendingForegroundNodeActionsResponse: Decodable {
|
||||
var nodeId: String?
|
||||
var actions: [PendingForegroundNodeAction]
|
||||
}
|
||||
|
||||
private struct PendingForegroundNodeActionsAckRequest: Encodable {
|
||||
var ids: [String]
|
||||
}
|
||||
|
||||
private func refreshShareRouteFromGateway() async {
|
||||
struct Params: Codable {
|
||||
var includeGlobal: Bool
|
||||
@@ -2196,40 +2225,102 @@ extension NodeAppModel {
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||
}
|
||||
|
||||
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
|
||||
guard !self.isBackgrounded else { return }
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.pendingForegroundActionDrainInFlight else { return }
|
||||
|
||||
self.pendingForegroundActionDrainInFlight = true
|
||||
defer { self.pendingForegroundActionDrainInFlight = false }
|
||||
|
||||
do {
|
||||
let payload = try await self.nodeGateway.request(
|
||||
method: "node.pending.pull",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 6)
|
||||
let decoded = try JSONDecoder().decode(
|
||||
PendingForegroundNodeActionsResponse.self,
|
||||
from: payload)
|
||||
guard !decoded.actions.isEmpty else { return }
|
||||
self.pendingActionLogger.info(
|
||||
"Pending actions pulled trigger=\(trigger, privacy: .public) "
|
||||
+ "count=\(decoded.actions.count, privacy: .public)")
|
||||
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingForegroundNodeActions(
|
||||
_ actions: [PendingForegroundNodeAction],
|
||||
trigger: String) async
|
||||
{
|
||||
for action in actions {
|
||||
guard !self.isBackgrounded else {
|
||||
self.pendingActionLogger.info(
|
||||
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
|
||||
return
|
||||
}
|
||||
let req = BridgeInvokeRequest(
|
||||
id: action.id,
|
||||
command: action.command,
|
||||
paramsJSON: action.paramsJSON)
|
||||
let result = await self.handleInvoke(req)
|
||||
self.pendingActionLogger.info(
|
||||
"Pending action replay trigger=\(trigger, privacy: .public) "
|
||||
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
|
||||
+ "ok=\(result.ok, privacy: .public)")
|
||||
guard result.ok else { return }
|
||||
let acked = await self.ackPendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
trigger: trigger,
|
||||
command: action.command)
|
||||
guard acked else { return }
|
||||
}
|
||||
}
|
||||
|
||||
private func ackPendingForegroundNodeAction(
|
||||
id: String,
|
||||
trigger: String,
|
||||
command: String) async -> Bool
|
||||
{
|
||||
do {
|
||||
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
|
||||
let paramsJSON = String(decoding: payload, as: UTF8.self)
|
||||
_ = try await self.nodeGateway.request(
|
||||
method: "node.pending.ack",
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: 6)
|
||||
return true
|
||||
} catch {
|
||||
self.pendingActionLogger.error(
|
||||
"Pending action ack failed trigger=\(trigger, privacy: .public) "
|
||||
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
|
||||
+ "error=\(String(describing: error), privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
|
||||
case .dropMissingFields:
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
return
|
||||
}
|
||||
|
||||
if self.seenWatchReplyIds.contains(replyId) {
|
||||
case .deduped(let replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.seenWatchReplyIds.insert(replyId)
|
||||
|
||||
if await !self.isGatewayConnected() {
|
||||
self.queuedWatchReplies.append(event)
|
||||
case .queue(let replyId, let actionId):
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
|
||||
return
|
||||
case .forward:
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.queuedWatchReplies.isEmpty else { return }
|
||||
|
||||
let pending = self.queuedWatchReplies
|
||||
self.queuedWatchReplies.removeAll()
|
||||
for event in pending {
|
||||
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
@@ -2259,7 +2350,7 @@ extension NodeAppModel {
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2852,13 +2943,26 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.queuedWatchReplies.count
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
self.gatewayConnected = connected
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
let mapped = actions.map { action in
|
||||
PendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
command: action.command,
|
||||
paramsJSON: action.paramsJSON,
|
||||
enqueuedAtMs: nil)
|
||||
}
|
||||
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WatchReplyCoordinator {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case deduped(replyId: String)
|
||||
case queue(replyId: String, actionId: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
private var queuedReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenReplyIds = Set<String>()
|
||||
|
||||
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenReplyIds.contains(replyId) {
|
||||
return .deduped(replyId: replyId)
|
||||
}
|
||||
self.seenReplyIds.insert(replyId)
|
||||
if !isGatewayConnected {
|
||||
self.queuedReplies.append(event)
|
||||
return .queue(replyId: replyId, actionId: actionId)
|
||||
}
|
||||
return .forward
|
||||
}
|
||||
|
||||
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
|
||||
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
|
||||
let pending = self.queuedReplies
|
||||
self.queuedReplies.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchQuickReplyEvent) {
|
||||
self.queuedReplies.insert(event, at: 0)
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedReplies.count
|
||||
}
|
||||
}
|
||||
3
apps/ios/Sources/Voice/TalkDefaults.swift
Normal file
3
apps/ios/Sources/Voice/TalkDefaults.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
enum TalkDefaults {
|
||||
static let silenceTimeoutMs = 900
|
||||
}
|
||||
69
apps/ios/Sources/Voice/TalkModeGatewayConfig.swift
Normal file
69
apps/ios/Sources/Voice/TalkModeGatewayConfig.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TalkModeGatewayConfigState {
|
||||
let activeProvider: String
|
||||
let normalizedPayload: Bool
|
||||
let missingResolvedPayload: Bool
|
||||
let defaultVoiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let defaultModelId: String
|
||||
let defaultOutputFormat: String?
|
||||
let rawConfigApiKey: String?
|
||||
let interruptOnSpeech: Bool?
|
||||
let silenceTimeoutMs: Int
|
||||
}
|
||||
|
||||
enum TalkModeGatewayConfigParser {
|
||||
static func parse(
|
||||
config: [String: Any],
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
defaultProvider: defaultProvider,
|
||||
allowLegacyFallback: false)
|
||||
let activeProvider = selection?.provider ?? defaultProvider
|
||||
let activeConfig = selection?.config
|
||||
let defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let voiceAliases: [String: String]
|
||||
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
|
||||
var resolved: [String: String] = [:]
|
||||
for (key, value) in aliases {
|
||||
guard let id = value.stringValue else { continue }
|
||||
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
|
||||
resolved[normalizedKey] = trimmedId
|
||||
}
|
||||
voiceAliases = resolved
|
||||
} else {
|
||||
voiceAliases = [:]
|
||||
}
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback
|
||||
let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: activeProvider,
|
||||
normalizedPayload: selection?.normalizedPayload == true,
|
||||
missingResolvedPayload: talk != nil && selection == nil,
|
||||
defaultVoiceId: defaultVoiceId,
|
||||
voiceAliases: voiceAliases,
|
||||
defaultModelId: defaultModelId,
|
||||
defaultOutputFormat: defaultOutputFormat,
|
||||
rawConfigApiKey: rawConfigApiKey,
|
||||
interruptOnSpeech: interruptOnSpeech,
|
||||
silenceTimeoutMs: silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ final class TalkModeManager: NSObject {
|
||||
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let defaultTalkProvider = "elevenlabs"
|
||||
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
|
||||
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
|
||||
var isEnabled: Bool = false
|
||||
var isListening: Bool = false
|
||||
@@ -97,7 +98,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private var gateway: GatewayNodeSession?
|
||||
private var gatewayConnected = false
|
||||
private let silenceWindow: TimeInterval = 0.9
|
||||
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
||||
private var lastAudioActivity: Date?
|
||||
private var noiseFloorSamples: [Double] = []
|
||||
private var noiseFloor: Double?
|
||||
@@ -1969,38 +1970,6 @@ extension TalkModeManager {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
struct TalkProviderConfigSelection {
|
||||
let provider: String
|
||||
let config: [String: Any]
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? {
|
||||
guard let talk else { return nil }
|
||||
let rawProvider = talk["provider"] as? String
|
||||
let rawProviders = talk["providers"] as? [String: Any]
|
||||
guard rawProvider != nil || rawProviders != nil else { return nil }
|
||||
let providers = rawProviders ?? [:]
|
||||
let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in
|
||||
guard
|
||||
let providerID = Self.normalizedTalkProviderID(entry.key),
|
||||
let config = entry.value as? [String: Any]
|
||||
else { return }
|
||||
acc[providerID] = config
|
||||
}
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.min() ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: normalizedProviders[providerID] ?? [:])
|
||||
}
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
self.pcmFormatUnavailable = false
|
||||
@@ -2012,40 +1981,27 @@ extension TalkModeManager {
|
||||
)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let selection = Self.selectTalkProviderConfig(talk)
|
||||
if talk != nil, selection == nil {
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: config,
|
||||
defaultProvider: Self.defaultTalkProvider,
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs)
|
||||
if parsed.missingResolvedPayload {
|
||||
GatewayDiagnostics.log(
|
||||
"talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers")
|
||||
}
|
||||
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
|
||||
let activeConfig = selection?.config
|
||||
self.defaultVoiceId = (activeConfig?["voiceId"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = activeConfig?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
for (key, value) in aliases {
|
||||
guard let id = value as? String else { continue }
|
||||
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
|
||||
resolved[normalizedKey] = trimmedId
|
||||
}
|
||||
self.voiceAliases = resolved
|
||||
} else {
|
||||
self.voiceAliases = [:]
|
||||
"talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
let activeProvider = parsed.activeProvider
|
||||
self.defaultVoiceId = parsed.defaultVoiceId
|
||||
self.voiceAliases = parsed.voiceAliases
|
||||
if !self.voiceOverrideActive {
|
||||
self.currentVoiceId = self.defaultVoiceId
|
||||
}
|
||||
let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
|
||||
self.defaultModelId = parsed.defaultModelId
|
||||
if !self.modelOverrideActive {
|
||||
self.currentModelId = self.defaultModelId
|
||||
}
|
||||
self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.defaultOutputFormat = parsed.defaultOutputFormat
|
||||
let rawConfigApiKey = parsed.rawConfigApiKey
|
||||
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
|
||||
let localApiKey = Self.normalizedTalkApiKey(
|
||||
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
|
||||
@@ -2064,11 +2020,13 @@ extension TalkModeManager {
|
||||
self.gatewayTalkDefaultModelId = self.defaultModelId
|
||||
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
|
||||
self.gatewayTalkConfigLoaded = true
|
||||
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
|
||||
if let interrupt = parsed.interruptOnSpeech {
|
||||
self.interruptOnSpeech = interrupt
|
||||
}
|
||||
if selection != nil {
|
||||
GatewayDiagnostics.log("talk config provider=\(activeProvider)")
|
||||
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
|
||||
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
|
||||
GatewayDiagnostics.log(
|
||||
"talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)")
|
||||
}
|
||||
} catch {
|
||||
self.defaultModelId = Self.defaultModelIdFallback
|
||||
@@ -2079,6 +2037,7 @@ extension TalkModeManager {
|
||||
self.gatewayTalkDefaultModelId = nil
|
||||
self.gatewayTalkApiKeyConfigured = false
|
||||
self.gatewayTalkConfigLoaded = false
|
||||
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Sources/OpenClawApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/NodeAppModel+Canvas.swift
|
||||
Sources/Model/WatchReplyCoordinator.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
</dict>
|
||||
|
||||
75
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal file
75
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
private let iOSSilenceTimeoutMs = 900
|
||||
|
||||
@Suite struct TalkConfigParsingTests {
|
||||
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
|
||||
let talk: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
"voiceId": "voice-legacy",
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: Any] = [
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "legacy-key", // pragma: allowlist secret
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func readsConfiguredSilenceTimeoutMs() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": 1500,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == 1500)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenMissing() {
|
||||
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenInvalid() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": 0,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenBool() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": true,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
#expect(payload?["result"] as? String == "2")
|
||||
}
|
||||
|
||||
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
|
||||
await appModel._test_applyPendingForegroundNodeActions([
|
||||
(
|
||||
id: "pending-nav-1",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString == "http://example.com/")
|
||||
}
|
||||
|
||||
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.setScenePhase(.background)
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
|
||||
await appModel._test_applyPendingForegroundNodeActions([
|
||||
(
|
||||
id: "pending-nav-bg",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
|
||||
|
||||
@@ -3,33 +3,7 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct TalkModeConfigParsingTests {
|
||||
@Test func prefersNormalizedTalkProviderPayload() {
|
||||
let talk: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
"voiceId": "voice-legacy",
|
||||
]
|
||||
|
||||
let selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.config["voiceId"] as? String == "voice-normalized")
|
||||
}
|
||||
|
||||
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: Any] = [
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "legacy-key", // pragma: allowlist secret
|
||||
]
|
||||
|
||||
let selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Suite struct TalkModeManagerTests {
|
||||
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
|
||||
let error = NSError(
|
||||
domain: "ElevenLabsTTS",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<key>NSExtension</key>
|
||||
|
||||
@@ -25,6 +25,15 @@ schemes:
|
||||
test:
|
||||
targets:
|
||||
- OpenClawTests
|
||||
- OpenClawLogicTests
|
||||
OpenClawLogicTests:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawLogicTests: all
|
||||
test:
|
||||
targets:
|
||||
- OpenClawLogicTests
|
||||
|
||||
targets:
|
||||
OpenClaw:
|
||||
@@ -98,7 +107,7 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@@ -117,8 +126,11 @@ targets:
|
||||
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
|
||||
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
@@ -156,7 +168,7 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
@@ -193,7 +205,7 @@ targets:
|
||||
path: ActivityWidget/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Activity
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
NSSupportsLiveActivities: true
|
||||
NSExtension:
|
||||
@@ -219,7 +231,7 @@ targets:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
@@ -244,7 +256,7 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
@@ -259,6 +271,8 @@ targets:
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests
|
||||
excludes:
|
||||
- Logic
|
||||
dependencies:
|
||||
- target: OpenClaw
|
||||
- package: Swabble
|
||||
@@ -279,5 +293,31 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
|
||||
OpenClawLogicTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests/Logic
|
||||
dependencies:
|
||||
- package: OpenClawKit
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawLogicTests
|
||||
CFBundleShortVersionString: "2026.3.9"
|
||||
CFBundleVersion: "20260308"
|
||||
|
||||
@@ -4,40 +4,3 @@ import OpenClawKit
|
||||
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = OpenClawKit.AnyCodable
|
||||
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
self.value as? Bool
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
self.value as? Int
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
self.value as? Double
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
self.value as? [String: AnyCodable]
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
self.value as? [AnyCodable]
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
private var isApplyingRemoteTokenConfig = false
|
||||
private var configWatcher: ConfigFileWatcher?
|
||||
private var suppressVoiceWakeGlobalSync = false
|
||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||
@@ -213,6 +214,18 @@ final class AppState {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var remoteToken: String {
|
||||
didSet {
|
||||
guard !self.isApplyingRemoteTokenConfig else { return }
|
||||
self.remoteTokenDirty = true
|
||||
self.remoteTokenUnsupported = false
|
||||
self.syncGatewayConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var remoteTokenDirty = false
|
||||
private(set) var remoteTokenUnsupported = false
|
||||
|
||||
var remoteIdentity: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
@@ -281,6 +294,7 @@ final class AppState {
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
@@ -297,6 +311,9 @@ final class AppState {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteUrl = configRemoteUrl ?? ""
|
||||
self.remoteToken = configRemoteToken.textFieldValue
|
||||
self.remoteTokenDirty = false
|
||||
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -374,13 +391,29 @@ final class AppState {
|
||||
return false
|
||||
}
|
||||
|
||||
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
|
||||
let nextToken = tokenValue.textFieldValue
|
||||
let unsupported = tokenValue.isUnsupportedNonString
|
||||
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.isApplyingRemoteTokenConfig = true
|
||||
self.remoteToken = nextToken
|
||||
self.isApplyingRemoteTokenConfig = false
|
||||
self.remoteTokenDirty = false
|
||||
self.remoteTokenUnsupported = unsupported
|
||||
}
|
||||
|
||||
private static func updatedRemoteGatewayConfig(
|
||||
current: [String: Any],
|
||||
transport: RemoteTransport,
|
||||
remoteUrl: String,
|
||||
remoteHost: String?,
|
||||
remoteTarget: String,
|
||||
remoteIdentity: String) -> (remote: [String: Any], changed: Bool)
|
||||
remoteIdentity: String,
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool)
|
||||
{
|
||||
var remote = current
|
||||
var changed = false
|
||||
@@ -417,6 +450,10 @@ final class AppState {
|
||||
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed
|
||||
}
|
||||
|
||||
if remoteTokenDirty {
|
||||
changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed
|
||||
}
|
||||
|
||||
return (remote, changed)
|
||||
}
|
||||
|
||||
@@ -439,6 +476,7 @@ final class AppState {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||
let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root)
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
@@ -470,6 +508,7 @@ final class AppState {
|
||||
if remoteUrlText != self.remoteUrl {
|
||||
self.remoteUrl = remoteUrlText
|
||||
}
|
||||
self.applyRemoteTokenState(remoteToken)
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
@@ -496,14 +535,20 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
private func syncGatewayConfigIfNeeded() {
|
||||
guard !self.isPreview, !self.isInitializing else { return }
|
||||
private static func syncedGatewayRoot(
|
||||
currentRoot: [String: Any],
|
||||
connectionMode: ConnectionMode,
|
||||
remoteTransport: RemoteTransport,
|
||||
remoteTarget: String,
|
||||
remoteIdentity: String,
|
||||
remoteUrl: String,
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool)
|
||||
{
|
||||
var root = currentRoot
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -512,49 +557,70 @@ final class AppState {
|
||||
case .unconfigured:
|
||||
nil
|
||||
}
|
||||
let remoteHost = connectionMode == .remote
|
||||
? CommandResolver.parseSSHTarget(remoteTarget)?.host
|
||||
: nil
|
||||
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let desiredMode {
|
||||
if currentMode != desiredMode {
|
||||
gateway["mode"] = desiredMode
|
||||
changed = true
|
||||
}
|
||||
} else if currentMode != nil {
|
||||
gateway.removeValue(forKey: "mode")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote {
|
||||
let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host
|
||||
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let updated = Self.updatedRemoteGatewayConfig(
|
||||
current: currentRemote,
|
||||
transport: remoteTransport,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteHost: remoteHost,
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty)
|
||||
if updated.changed {
|
||||
gateway["remote"] = updated.remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return (currentRoot, false) }
|
||||
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
return (root, true)
|
||||
}
|
||||
|
||||
private func syncGatewayConfigIfNeeded() {
|
||||
guard !self.isPreview, !self.isInitializing else { return }
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let remoteToken = self.remoteToken
|
||||
let remoteTokenDirty = self.remoteTokenDirty
|
||||
|
||||
Task { @MainActor in
|
||||
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
||||
var root = OpenClawConfigFile.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let desiredMode {
|
||||
if currentMode != desiredMode {
|
||||
gateway["mode"] = desiredMode
|
||||
changed = true
|
||||
}
|
||||
} else if currentMode != nil {
|
||||
gateway.removeValue(forKey: "mode")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote {
|
||||
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let updated = Self.updatedRemoteGatewayConfig(
|
||||
current: currentRemote,
|
||||
transport: remoteTransport,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteHost: remoteHost,
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity)
|
||||
if updated.changed {
|
||||
gateway["remote"] = updated.remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
OpenClawConfigFile.saveDict(root)
|
||||
let synced = Self.syncedGatewayRoot(
|
||||
currentRoot: OpenClawConfigFile.loadDict(),
|
||||
connectionMode: connectionMode,
|
||||
remoteTransport: remoteTransport,
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty)
|
||||
guard synced.changed else { return }
|
||||
OpenClawConfigFile.saveDict(synced.root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +763,7 @@ extension AppState {
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteToken = "example-token"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/openclaw"
|
||||
state.remoteCliPath = ""
|
||||
@@ -704,6 +771,53 @@ extension AppState {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension AppState {
|
||||
static func _testUpdatedRemoteGatewayConfig(
|
||||
current: [String: Any],
|
||||
transport: RemoteTransport,
|
||||
remoteUrl: String,
|
||||
remoteHost: String?,
|
||||
remoteTarget: String,
|
||||
remoteIdentity: String,
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> [String: Any]
|
||||
{
|
||||
Self.updatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
transport: transport,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteHost: remoteHost,
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty).remote
|
||||
}
|
||||
|
||||
static func _testSyncedGatewayRoot(
|
||||
currentRoot: [String: Any],
|
||||
connectionMode: ConnectionMode,
|
||||
remoteTransport: RemoteTransport,
|
||||
remoteTarget: String,
|
||||
remoteIdentity: String,
|
||||
remoteUrl: String,
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> [String: Any]
|
||||
{
|
||||
Self.syncedGatewayRoot(
|
||||
currentRoot: currentRoot,
|
||||
connectionMode: connectionMode,
|
||||
remoteTransport: remoteTransport,
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty).root
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
enum AppStateStore {
|
||||
static let shared = AppState()
|
||||
|
||||
@@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport {
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
state: AppState)
|
||||
{
|
||||
if state.remoteTransport == .direct {
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
let preferredTransport = self.preferredTransport(
|
||||
for: gateway,
|
||||
current: state.remoteTransport)
|
||||
if preferredTransport != state.remoteTransport {
|
||||
state.remoteTransport = preferredTransport
|
||||
}
|
||||
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
@@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
static func preferredTransport(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
current: AppState.RemoteTransport) -> AppState.RemoteTransport
|
||||
{
|
||||
if self.shouldPreferDirectTransport(for: gateway) {
|
||||
return .direct
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
static func shouldPreferDirectTransport(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
|
||||
{
|
||||
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
|
||||
if gateway.stableID.hasPrefix("tailscale-serve|") {
|
||||
return true
|
||||
}
|
||||
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return host.hasSuffix(".ts.net")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +188,7 @@ actor GatewayEndpointStore {
|
||||
|
||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
return GatewayRemoteConfig.resolveTokenString(root: root)
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
|
||||
@@ -2,6 +2,28 @@ import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
enum TokenValue: Equatable {
|
||||
case missing
|
||||
case plaintext(String)
|
||||
case unsupportedNonString
|
||||
|
||||
var textFieldValue: String {
|
||||
switch self {
|
||||
case let .plaintext(token):
|
||||
token
|
||||
case .missing, .unsupportedNonString:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
var isUnsupportedNonString: Bool {
|
||||
if case .unsupportedNonString = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
@@ -24,6 +46,29 @@ enum GatewayRemoteConfig {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveTokenValue(root: [String: Any]) -> TokenValue {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let tokenRaw = remote["token"]
|
||||
else {
|
||||
return .missing
|
||||
}
|
||||
guard let tokenString = tokenRaw as? String else {
|
||||
return .unsupportedNonString
|
||||
}
|
||||
let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? .missing : .plaintext(trimmed)
|
||||
}
|
||||
|
||||
static func resolveTokenString(root: [String: Any]) -> String? {
|
||||
switch self.resolveTokenValue(root: root) {
|
||||
case let .plaintext(token):
|
||||
token
|
||||
case .missing, .unsupportedNonString:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
|
||||
@@ -149,6 +149,7 @@ struct GeneralSettings: View {
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
}
|
||||
self.remoteTokenRow
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
@@ -291,6 +292,30 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteTokenRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway token")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Text("Used when the remote gateway requires token auth.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
if self.state.remoteTokenUnsupported {
|
||||
Text(
|
||||
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteTestButton(disabled: Bool) -> some View {
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
@@ -692,6 +717,7 @@ extension GeneralSettings {
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteToken = "example-token"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/openclaw"
|
||||
state.remoteCliPath = "/tmp/openclaw"
|
||||
|
||||
@@ -199,6 +199,25 @@ extension OnboardingView {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Gateway token")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if self.state.remoteTokenUnsupported {
|
||||
GridRow {
|
||||
Text("")
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
Text(
|
||||
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: fieldWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
if self.state.remoteTransport == .direct {
|
||||
GridRow {
|
||||
Text("Gateway URL")
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.8</string>
|
||||
<string>2026.3.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603080</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
3
apps/macos/Sources/OpenClaw/TalkDefaults.swift
Normal file
3
apps/macos/Sources/OpenClaw/TalkDefaults.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
enum TalkDefaults {
|
||||
static let silenceTimeoutMs = 700
|
||||
}
|
||||
104
apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift
Normal file
104
apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TalkModeGatewayConfigState {
|
||||
let activeProvider: String
|
||||
let normalizedPayload: Bool
|
||||
let missingResolvedPayload: Bool
|
||||
let voiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let modelId: String?
|
||||
let outputFormat: String?
|
||||
let interruptOnSpeech: Bool
|
||||
let silenceTimeoutMs: Int
|
||||
let apiKey: String?
|
||||
let seamColorHex: String?
|
||||
}
|
||||
|
||||
enum TalkModeGatewayConfigParser {
|
||||
static func parse(
|
||||
snapshot: ConfigSnapshot,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envApiKey: String?
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let talk = snapshot.config?["talk"]?.dictionaryValue
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
|
||||
let activeProvider = selection?.provider ?? defaultProvider
|
||||
let activeConfig = selection?.config
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
let ui = snapshot.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let voice = activeConfig?["voiceId"]?.stringValue
|
||||
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
|
||||
let resolvedAliases: [String: String] =
|
||||
rawAliases?.reduce(into: [:]) { acc, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !key.isEmpty, !value.isEmpty else { return }
|
||||
acc[key] = value
|
||||
} ?? [:]
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let resolvedVoice: String? = if activeProvider == defaultProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
} else {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
|
||||
}
|
||||
let resolvedApiKey: String? = if activeProvider == defaultProvider {
|
||||
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
|
||||
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: activeProvider,
|
||||
normalizedPayload: selection?.normalizedPayload == true,
|
||||
missingResolvedPayload: talk != nil && selection == nil,
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: resolvedAliases,
|
||||
modelId: resolvedModel,
|
||||
outputFormat: outputFormat,
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
|
||||
}
|
||||
|
||||
static func fallback(
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envApiKey: String?
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let resolvedVoice =
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: "elevenlabs",
|
||||
normalizedPayload: false,
|
||||
missingResolvedPayload: false,
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: [:],
|
||||
modelId: defaultModelIdFallback,
|
||||
outputFormat: nil,
|
||||
interruptOnSpeech: true,
|
||||
silenceTimeoutMs: defaultSilenceTimeoutMs,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: nil)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ actor TalkModeRuntime {
|
||||
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let defaultTalkProvider = "elevenlabs"
|
||||
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
|
||||
|
||||
private final class RMSMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -66,10 +67,15 @@ actor TalkModeRuntime {
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
|
||||
private let silenceWindow: TimeInterval = 0.7
|
||||
private var silenceWindow: TimeInterval = .init(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000
|
||||
private let minSpeechRMS: Double = 1e-3
|
||||
private let speechBoostFactor: Double = 6.0
|
||||
|
||||
static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) {
|
||||
request.shouldReportPartialResults = true
|
||||
request.taskHint = .dictation
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
@@ -176,9 +182,9 @@ actor TalkModeRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
guard let request = self.recognitionRequest else { return }
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
Self.configureRecognitionRequest(request)
|
||||
self.recognitionRequest = request
|
||||
|
||||
if self.audioEngine == nil {
|
||||
self.audioEngine = AVAudioEngine()
|
||||
@@ -778,6 +784,7 @@ extension TalkModeRuntime {
|
||||
}
|
||||
self.defaultOutputFormat = cfg.outputFormat
|
||||
self.interruptOnSpeech = cfg.interruptOnSpeech
|
||||
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
|
||||
self.apiKey = cfg.apiKey
|
||||
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||
let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none"
|
||||
@@ -787,95 +794,21 @@ extension TalkModeRuntime {
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
|
||||
}
|
||||
|
||||
private struct TalkRuntimeConfig {
|
||||
let voiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let modelId: String?
|
||||
let outputFormat: String?
|
||||
let interruptOnSpeech: Bool
|
||||
let apiKey: String?
|
||||
}
|
||||
|
||||
struct TalkProviderConfigSelection {
|
||||
let provider: String
|
||||
let config: [String: AnyCodable]
|
||||
let normalizedPayload: Bool
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? {
|
||||
if let typed = value.value as? [String: AnyCodable] {
|
||||
return typed
|
||||
}
|
||||
if let foundation = value.value as? [String: Any] {
|
||||
return foundation.mapValues(AnyCodable.init)
|
||||
}
|
||||
if let nsDict = value.value as? NSDictionary {
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for case let (key as String, raw) in nsDict {
|
||||
converted[key] = AnyCodable(raw)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
|
||||
guard let raw else { return [:] }
|
||||
var providerMap: [String: AnyCodable] = [:]
|
||||
if let typed = raw.value as? [String: AnyCodable] {
|
||||
providerMap = typed
|
||||
} else if let foundation = raw.value as? [String: Any] {
|
||||
providerMap = foundation.mapValues(AnyCodable.init)
|
||||
} else if let nsDict = raw.value as? NSDictionary {
|
||||
for case let (key as String, value) in nsDict {
|
||||
providerMap[key] = AnyCodable(value)
|
||||
}
|
||||
} else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
|
||||
guard
|
||||
let providerID = Self.normalizedTalkProviderID(entry.key),
|
||||
let providerConfig = Self.normalizedTalkProviderConfig(entry.value)
|
||||
else { return }
|
||||
acc[providerID] = providerConfig
|
||||
}
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
|
||||
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)")
|
||||
}
|
||||
|
||||
static func selectTalkProviderConfig(
|
||||
_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection?
|
||||
{
|
||||
guard let talk else { return nil }
|
||||
let rawProvider = talk["provider"]?.stringValue
|
||||
let rawProviders = talk["providers"]
|
||||
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
|
||||
if hasNormalizedPayload {
|
||||
let normalizedProviders = Self.normalizedTalkProviders(rawProviders)
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.min() ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: normalizedProviders[providerID] ?? [:],
|
||||
normalizedPayload: true)
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider: Self.defaultTalkProvider,
|
||||
config: talk,
|
||||
normalizedPayload: false)
|
||||
TalkConfigParsing.selectProviderConfig(talk, defaultProvider: self.defaultTalkProvider)
|
||||
}
|
||||
|
||||
private func fetchTalkConfig() async -> TalkRuntimeConfig {
|
||||
static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int {
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
private func fetchTalkConfig() async -> TalkModeGatewayConfigState {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -886,67 +819,34 @@ extension TalkModeRuntime {
|
||||
method: .talkConfig,
|
||||
params: ["includeSecrets": AnyCodable(true)],
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let selection = Self.selectTalkProviderConfig(talk)
|
||||
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
|
||||
let activeConfig = selection?.config
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
snapshot: snap,
|
||||
defaultProvider: Self.defaultTalkProvider,
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
|
||||
envVoice: envVoice,
|
||||
sagVoice: sagVoice,
|
||||
envApiKey: envApiKey)
|
||||
if parsed.missingResolvedPayload {
|
||||
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
await MainActor.run {
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
AppStateStore.shared.seamColorHex = parsed.seamColorHex
|
||||
}
|
||||
let voice = activeConfig?["voiceId"]?.stringValue
|
||||
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
|
||||
let resolvedAliases: [String: String] =
|
||||
rawAliases?.reduce(into: [:]) { acc, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !key.isEmpty, !value.isEmpty else { return }
|
||||
acc[key] = value
|
||||
} ?? [:]
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
} else {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
|
||||
}
|
||||
let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider {
|
||||
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
|
||||
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
if activeProvider != Self.defaultTalkProvider {
|
||||
if parsed.activeProvider != Self.defaultTalkProvider {
|
||||
self.ttsLogger
|
||||
.info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice")
|
||||
} else if selection?.normalizedPayload == true {
|
||||
self.ttsLogger.info("talk config provider elevenlabs")
|
||||
.info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
|
||||
} else if parsed.normalizedPayload {
|
||||
self.ttsLogger.info("talk config provider from talk.resolved")
|
||||
}
|
||||
return TalkRuntimeConfig(
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: resolvedAliases,
|
||||
modelId: resolvedModel,
|
||||
outputFormat: outputFormat,
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
apiKey: resolvedApiKey)
|
||||
return parsed
|
||||
} catch {
|
||||
let resolvedVoice =
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
|
||||
return TalkRuntimeConfig(
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: [:],
|
||||
modelId: Self.defaultModelIdFallback,
|
||||
outputFormat: nil,
|
||||
interruptOnSpeech: true,
|
||||
apiKey: resolvedApiKey)
|
||||
return TalkModeGatewayConfigParser.fallback(
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
|
||||
envVoice: envVoice,
|
||||
sagVoice: sagVoice,
|
||||
envApiKey: envApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel {
|
||||
var attempt = 0
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||
let hasResults = await MainActor.run {
|
||||
if self.filterLocalGateways {
|
||||
return !self.gateways.isEmpty
|
||||
}
|
||||
return self.gateways.contains(where: { !$0.isLocal })
|
||||
let shouldContinue = await MainActor.run {
|
||||
Self.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: self.gateways,
|
||||
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
|
||||
}
|
||||
if hasResults { return }
|
||||
if !shouldContinue { return }
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
|
||||
if !beacons.isEmpty {
|
||||
@@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways _: [DiscoveredGateway],
|
||||
tailscaleServeGateways: [DiscoveredGateway]) -> Bool
|
||||
{
|
||||
// Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the
|
||||
// probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found.
|
||||
tailscaleServeGateways.isEmpty
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
|
||||
@@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
process.environment = self.commandEnvironment()
|
||||
let outPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
@@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery {
|
||||
return output?.isEmpty == false ? output : nil
|
||||
}
|
||||
|
||||
static func commandEnvironment(
|
||||
base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String]
|
||||
{
|
||||
var env = base
|
||||
let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if term.isEmpty {
|
||||
// The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing,
|
||||
// which is common for GUI-launched app environments.
|
||||
env["TERM"] = "dumb"
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
private static func parseStatus(_ raw: String) -> TailscaleStatus? {
|
||||
guard let data = raw.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(TailscaleStatus.self, from: data)
|
||||
|
||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {}
|
||||
|
||||
public struct NodePendingAckParams: Codable, Sendable {
|
||||
public let ids: [String]
|
||||
|
||||
public init(
|
||||
ids: [String])
|
||||
{
|
||||
self.ids = ids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ids
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
@@ -936,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainParams: Codable, Sendable {
|
||||
public let maxitems: Int?
|
||||
|
||||
public init(
|
||||
maxitems: Int?)
|
||||
{
|
||||
self.maxitems = maxitems
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case maxitems = "maxItems"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let items: [[String: AnyCodable]]
|
||||
public let hasmore: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
items: [[String: AnyCodable]],
|
||||
hasmore: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.items = items
|
||||
self.hasmore = hasmore
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case items
|
||||
case hasmore = "hasMore"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let type: String
|
||||
public let priority: String?
|
||||
public let expiresinms: Int?
|
||||
public let wake: Bool?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
type: String,
|
||||
priority: String?,
|
||||
expiresinms: Int?,
|
||||
wake: Bool?)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.type = type
|
||||
self.priority = priority
|
||||
self.expiresinms = expiresinms
|
||||
self.wake = wake
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case type
|
||||
case priority
|
||||
case expiresinms = "expiresInMs"
|
||||
case wake
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let queued: [String: AnyCodable]
|
||||
public let waketriggered: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
queued: [String: AnyCodable],
|
||||
waketriggered: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.queued = queued
|
||||
self.waketriggered = waketriggered
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case queued
|
||||
case waketriggered = "wakeTriggered"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
@@ -3243,6 +3353,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let systeminputprovenance: [String: AnyCodable]?
|
||||
public let systemprovenancereceipt: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -3252,6 +3364,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
@@ -3260,6 +3374,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
@@ -3270,6 +3386,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case deliver
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct AppStateRemoteConfigTests {
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigSetsTrimmedToken() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [:],
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "/tmp/id_ed25519",
|
||||
remoteToken: " secret-token ",
|
||||
remoteTokenDirty: true)
|
||||
|
||||
#expect(remote["token"] as? String == "secret-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigClearsTokenWhenBlank() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["token": "old-token"],
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " ",
|
||||
remoteTokenDirty: true)
|
||||
|
||||
#expect((remote["token"] as? String) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() {
|
||||
let initialRoot: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"transport": "direct",
|
||||
"url": "wss://old-gateway.example",
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let sshRoot = AppState._testSyncedGatewayRoot(
|
||||
currentRoot: initialRoot,
|
||||
connectionMode: .remote,
|
||||
remoteTransport: .ssh,
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteUrl: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false)
|
||||
let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
|
||||
#expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
|
||||
|
||||
let localRoot = AppState._testSyncedGatewayRoot(
|
||||
currentRoot: sshRoot,
|
||||
connectionMode: .local,
|
||||
remoteTransport: .ssh,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteUrl: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false)
|
||||
let localGateway = localRoot["gateway"] as? [String: Any]
|
||||
let localRemote = localGateway?["remote"] as? [String: Any]
|
||||
#expect(localGateway?["mode"] as? String == "local")
|
||||
#expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: [
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
],
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " fresh-token ",
|
||||
remoteTokenDirty: true)
|
||||
|
||||
#expect(remote["token"] as? String == "fresh-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() {
|
||||
let current: [String: Any] = [
|
||||
"token": [
|
||||
"$secretRef": "gateway-token", // pragma: allowlist secret
|
||||
],
|
||||
]
|
||||
|
||||
let preserved = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false)
|
||||
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
|
||||
|
||||
let cleared = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
transport: .direct,
|
||||
remoteUrl: "wss://gateway.example",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "",
|
||||
remoteIdentity: "",
|
||||
remoteToken: " ",
|
||||
remoteTokenDirty: true)
|
||||
#expect((cleared["token"] as? String) == nil)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests {
|
||||
port: 2201) == "peter@studio.local:2201")
|
||||
}
|
||||
|
||||
@Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() {
|
||||
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Nearby Gateway",
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
lanHost: "nearby-gateway.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: nil,
|
||||
stableID: "bonjour|nearby-gateway",
|
||||
debugID: "bonjour",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: [dnsSdGateway],
|
||||
tailscaleServeGateways: []))
|
||||
}
|
||||
|
||||
@Test func `tailscale serve discovery stops after serve result is found`() {
|
||||
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Nearby Gateway",
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
lanHost: "nearby-gateway.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: nil,
|
||||
stableID: "bonjour|nearby-gateway",
|
||||
debugID: "bonjour",
|
||||
isLocal: false)
|
||||
let serveGateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Tailscale Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: [dnsSdGateway],
|
||||
tailscaleServeGateways: [serveGateway]))
|
||||
}
|
||||
|
||||
@Test func `dedupe key prefers resolved endpoint across sources`() {
|
||||
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct GatewayDiscoverySelectionSupportTests {
|
||||
private func makeGateway(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
tailnetDns: String? = nil,
|
||||
sshPort: Int = 22,
|
||||
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: serviceHost,
|
||||
servicePort: servicePort,
|
||||
lanHost: nil,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: servicePort,
|
||||
cliPath: nil,
|
||||
stableID: stableID,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
}
|
||||
|
||||
@Test func `selecting tailscale serve gateway switches to direct transport`() async {
|
||||
let tailnetHost = "gateway-host.tailnet-example.ts.net"
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: tailnetHost,
|
||||
servicePort: 443,
|
||||
tailnetDns: tailnetHost,
|
||||
stableID: "tailscale-serve|\(tailnetHost)"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "wss://\(tailnetHost)")
|
||||
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting merged tailnet gateway still switches to direct transport`() async {
|
||||
let tailnetHost = "gateway-host.tailnet-example.ts.net"
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: tailnetHost,
|
||||
servicePort: 443,
|
||||
tailnetDns: tailnetHost,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .direct)
|
||||
#expect(state.remoteUrl == "wss://\(tailnetHost)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting nearby lan gateway keeps ssh transport`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 18789,
|
||||
stableID: "bonjour|nearby-gateway"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .ssh)
|
||||
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,22 @@ struct GatewayEndpointStoreTests {
|
||||
#expect(token == nil)
|
||||
}
|
||||
|
||||
@Test func `resolve gateway password falls back to launchd`() {
|
||||
@Test func resolveGatewayTokenUsesRemoteConfigToken() {
|
||||
let token = GatewayEndpointStore._testResolveGatewayToken(
|
||||
isRemote: true,
|
||||
root: [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"token": " remote-token ",
|
||||
],
|
||||
],
|
||||
],
|
||||
env: [:],
|
||||
launchdSnapshot: nil)
|
||||
#expect(token == "remote-token")
|
||||
}
|
||||
|
||||
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
|
||||
let snapshot = self.makeLaunchAgentSnapshot(
|
||||
env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
|
||||
token: nil,
|
||||
|
||||
@@ -66,6 +66,15 @@ struct LowCoverageViewSmokeTests {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `talk overlay presents twice and dismisses`() async {
|
||||
let controller = TalkOverlayController()
|
||||
controller.present()
|
||||
controller.updateLevel(0.4)
|
||||
controller.present()
|
||||
controller.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `visual effect view hosts in NS hosting view`() {
|
||||
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
|
||||
_ = hosting.fittingSize
|
||||
|
||||
@@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests {
|
||||
#expect(TailscaleServeGatewayDiscovery
|
||||
.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
|
||||
}
|
||||
|
||||
@Test func `adds TERM for GUI-launched tailscale subprocesses`() {
|
||||
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
|
||||
"HOME": "/Users/tester",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
])
|
||||
|
||||
#expect(env["TERM"] == "dumb")
|
||||
#expect(env["HOME"] == "/Users/tester")
|
||||
#expect(env["PATH"] == "/usr/bin:/bin")
|
||||
}
|
||||
|
||||
@Test func `preserves existing TERM when building tailscale subprocess environment`() {
|
||||
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
|
||||
"TERM": "xterm-256color",
|
||||
"HOME": "/Users/tester",
|
||||
])
|
||||
|
||||
#expect(env["TERM"] == "xterm-256color")
|
||||
#expect(env["HOME"] == "/Users/tester")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeConfigParsingTests {
|
||||
@Test func `prefers normalized talk provider payload`() {
|
||||
@Test func `rejects normalized talk provider payload without resolved`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
@@ -15,9 +15,7 @@ struct TalkModeConfigParsingTests {
|
||||
]
|
||||
|
||||
let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == true)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func `falls back to legacy talk fields when normalized payload missing`() {
|
||||
@@ -32,4 +30,24 @@ struct TalkModeConfigParsingTests {
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
|
||||
}
|
||||
|
||||
@Test func `reads configured silence timeout ms`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(1500),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when missing`() {
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when invalid`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(0),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Speech
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeRuntimeSpeechTests {
|
||||
@Test func `speech request uses dictation defaults`() {
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
TalkModeRuntime.configureRecognitionRequest(request)
|
||||
|
||||
#expect(request.shouldReportPartialResults)
|
||||
#expect(request.taskHint == .dictation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public extension AnyCodable {
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
if let value = self.value as? Bool {
|
||||
return value
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() {
|
||||
return number.boolValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
if let value = self.value as? Int {
|
||||
return value
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
|
||||
let value = number.doubleValue
|
||||
if value > 0, value.rounded(.towardZero) == value, value <= Double(Int.max) {
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
if let value = self.value as? Double {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? Int {
|
||||
return Double(value)
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
|
||||
return number.doubleValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
if let value = self.value as? [String: AnyCodable] {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? [String: Any] {
|
||||
return value.mapValues(AnyCodable.init)
|
||||
}
|
||||
if let value = self.value as? NSDictionary {
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for case let (key as String, raw) in value {
|
||||
converted[key] = AnyCodable(raw)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
if let value = self.value as? [AnyCodable] {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? [Any] {
|
||||
return value.map(AnyCodable.init)
|
||||
}
|
||||
if let value = self.value as? NSArray {
|
||||
return value.map(AnyCodable.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues(\.foundationValue)
|
||||
case let array as [AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
case let dict as [String: Any]:
|
||||
dict.mapValues { AnyCodable($0).foundationValue }
|
||||
case let array as [Any]:
|
||||
array.map { AnyCodable($0).foundationValue }
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,50 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String) -> String? {
|
||||
let marker = "/__openclaw__/cap/"
|
||||
guard let markerRange = scopedUrl.range(of: marker) else { return nil }
|
||||
let capabilityStart = markerRange.upperBound
|
||||
let suffix = scopedUrl[capabilityStart...]
|
||||
let nextSlash = suffix.firstIndex(of: "/")
|
||||
let nextQuery = suffix.firstIndex(of: "?")
|
||||
let nextFragment = suffix.firstIndex(of: "#")
|
||||
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex
|
||||
guard capabilityStart < capabilityEnd else { return nil }
|
||||
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
|
||||
}
|
||||
|
||||
func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard var parsed = URLComponents(string: trimmed) else { return trimmed }
|
||||
|
||||
let parsedHost = parsed.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedIsLoopback = !parsedHost.isEmpty && LoopbackHost.isLoopback(parsedHost)
|
||||
|
||||
if !parsedHost.isEmpty, !parsedIsLoopback {
|
||||
guard let activeURL else { return trimmed }
|
||||
let isTLS = activeURL.scheme?.lowercased() == "wss"
|
||||
guard isTLS else { return trimmed }
|
||||
parsed.scheme = "https"
|
||||
if parsed.port == nil {
|
||||
let tlsPort = activeURL.port ?? 443
|
||||
parsed.port = (tlsPort == 443) ? nil : tlsPort
|
||||
}
|
||||
return parsed.string ?? trimmed
|
||||
}
|
||||
|
||||
guard let activeURL, let fallbackHost = activeURL.host, !LoopbackHost.isLoopback(fallbackHost) else {
|
||||
return trimmed
|
||||
}
|
||||
let isTLS = activeURL.scheme?.lowercased() == "wss"
|
||||
parsed.scheme = isTLS ? "https" : "http"
|
||||
parsed.host = fallbackHost
|
||||
let fallbackPort = activeURL.port ?? (isTLS ? 443 : 80)
|
||||
parsed.port = ((isTLS && fallbackPort == 443) || (!isTLS && fallbackPort == 80)) ? nil : fallbackPort
|
||||
return parsed.string ?? trimmed
|
||||
}
|
||||
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
|
||||
@@ -223,6 +267,46 @@ public actor GatewayNodeSession {
|
||||
self.canvasHostUrl
|
||||
}
|
||||
|
||||
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool {
|
||||
guard let channel = self.channel else { return false }
|
||||
do {
|
||||
let data = try await channel.request(
|
||||
method: "node.canvas.capability.refresh",
|
||||
params: [:],
|
||||
timeoutMs: Double(max(timeoutMs, 1)))
|
||||
guard
|
||||
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let rawCapability = payload["canvasCapability"] as? String
|
||||
else {
|
||||
self.logger.warning("node.canvas.capability.refresh missing canvasCapability")
|
||||
return false
|
||||
}
|
||||
let capability = rawCapability.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !capability.isEmpty else {
|
||||
self.logger.warning("node.canvas.capability.refresh returned empty capability")
|
||||
return false
|
||||
}
|
||||
let scopedUrl = self.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !scopedUrl.isEmpty else {
|
||||
self.logger.warning("node.canvas.capability.refresh missing local canvasHostUrl")
|
||||
return false
|
||||
}
|
||||
guard let refreshed = replaceCanvasCapabilityInScopedHostUrl(
|
||||
scopedUrl: scopedUrl,
|
||||
capability: capability)
|
||||
else {
|
||||
self.logger.warning("node.canvas.capability.refresh could not rewrite scoped canvas URL")
|
||||
return false
|
||||
}
|
||||
self.canvasHostUrl = refreshed
|
||||
return true
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"node.canvas.capability.refresh failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func currentRemoteAddress() -> String? {
|
||||
guard let url = self.activeURL else { return nil }
|
||||
guard let host = url.host else { return url.absoluteString }
|
||||
@@ -275,7 +359,7 @@ public actor GatewayNodeSession {
|
||||
switch push {
|
||||
case let .snapshot(ok):
|
||||
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
|
||||
self.canvasHostUrl = self.normalizeCanvasHostUrl(raw)
|
||||
if self.hasEverConnected {
|
||||
self.broadcastServerEvent(
|
||||
EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil))
|
||||
@@ -342,6 +426,10 @@ public actor GatewayNodeSession {
|
||||
await self.onConnected?()
|
||||
}
|
||||
|
||||
private func normalizeCanvasHostUrl(_ raw: String?) -> String? {
|
||||
canonicalizeCanvasHostUrl(raw: raw, activeURL: self.activeURL)
|
||||
}
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
self.broadcastServerEvent(evt)
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
@@ -350,16 +438,21 @@ public actor GatewayNodeSession {
|
||||
do {
|
||||
let request = try self.decodeInvokeRequest(from: payload)
|
||||
let timeoutLabel = request.timeoutMs.map(String.init) ?? "none"
|
||||
self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
|
||||
self.logger.info(
|
||||
"node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: request.id,
|
||||
command: request.command,
|
||||
paramsJSON: request.paramsJSON)
|
||||
self.logger.info("node invoke executing id=\(request.id, privacy: .public)")
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
self.logger.info(
|
||||
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -380,7 +473,8 @@ public actor GatewayNodeSession {
|
||||
|
||||
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
|
||||
guard let channel = self.channel else { return }
|
||||
self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
self.logger.info(
|
||||
"node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
|
||||
var params: [String: AnyCodable] = [
|
||||
"id": AnyCodable(request.id),
|
||||
"nodeId": AnyCodable(request.nodeId),
|
||||
@@ -398,7 +492,8 @@ public actor GatewayNodeSession {
|
||||
do {
|
||||
try await channel.send(method: "node.invoke.result", params: params)
|
||||
} catch {
|
||||
self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.logger.error(
|
||||
"node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
public struct TalkProviderConfigSelection: Sendable {
|
||||
public let provider: String
|
||||
public let config: [String: AnyCodable]
|
||||
public let normalizedPayload: Bool
|
||||
|
||||
public init(provider: String, config: [String: AnyCodable], normalizedPayload: Bool) {
|
||||
self.provider = provider
|
||||
self.config = config
|
||||
self.normalizedPayload = normalizedPayload
|
||||
}
|
||||
}
|
||||
|
||||
public enum TalkConfigParsing {
|
||||
public static func bridgeFoundationDictionary(_ raw: [String: Any]?) -> [String: AnyCodable]? {
|
||||
raw?.mapValues(AnyCodable.init)
|
||||
}
|
||||
|
||||
public static func selectProviderConfig(
|
||||
_ talk: [String: AnyCodable]?,
|
||||
defaultProvider: String,
|
||||
allowLegacyFallback: Bool = true,
|
||||
) -> TalkProviderConfigSelection? {
|
||||
guard let talk else { return nil }
|
||||
if let resolvedSelection = self.resolvedProviderConfig(talk) {
|
||||
return resolvedSelection
|
||||
}
|
||||
let hasNormalizedPayload = talk["provider"] != nil || talk["providers"] != nil
|
||||
if hasNormalizedPayload {
|
||||
return nil
|
||||
}
|
||||
guard allowLegacyFallback else { return nil }
|
||||
return TalkProviderConfigSelection(
|
||||
provider: defaultProvider,
|
||||
config: talk,
|
||||
normalizedPayload: false)
|
||||
}
|
||||
|
||||
public static func resolvedPositiveInt(_ value: AnyCodable?, fallback: Int) -> Int {
|
||||
if let timeout = value?.intValue, timeout > 0 {
|
||||
return timeout
|
||||
}
|
||||
if
|
||||
let timeout = value?.doubleValue,
|
||||
timeout > 0,
|
||||
timeout.rounded(.towardZero) == timeout,
|
||||
timeout <= Double(Int.max)
|
||||
{
|
||||
return Int(timeout)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
public static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?, fallback: Int) -> Int {
|
||||
self.resolvedPositiveInt(talk?["silenceTimeoutMs"], fallback: fallback)
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func resolvedProviderConfig(
|
||||
_ talk: [String: AnyCodable]
|
||||
) -> TalkProviderConfigSelection? {
|
||||
guard
|
||||
let resolved = talk["resolved"]?.dictionaryValue,
|
||||
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
|
||||
else { return nil }
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: resolved["config"]?.dictionaryValue ?? [:],
|
||||
normalizedPayload: true)
|
||||
}
|
||||
}
|
||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {}
|
||||
|
||||
public struct NodePendingAckParams: Codable, Sendable {
|
||||
public let ids: [String]
|
||||
|
||||
public init(
|
||||
ids: [String])
|
||||
{
|
||||
self.ids = ids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ids
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
@@ -936,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainParams: Codable, Sendable {
|
||||
public let maxitems: Int?
|
||||
|
||||
public init(
|
||||
maxitems: Int?)
|
||||
{
|
||||
self.maxitems = maxitems
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case maxitems = "maxItems"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let items: [[String: AnyCodable]]
|
||||
public let hasmore: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
items: [[String: AnyCodable]],
|
||||
hasmore: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.items = items
|
||||
self.hasmore = hasmore
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case items
|
||||
case hasmore = "hasMore"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let type: String
|
||||
public let priority: String?
|
||||
public let expiresinms: Int?
|
||||
public let wake: Bool?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
type: String,
|
||||
priority: String?,
|
||||
expiresinms: Int?,
|
||||
wake: Bool?)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.type = type
|
||||
self.priority = priority
|
||||
self.expiresinms = expiresinms
|
||||
self.wake = wake
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case type
|
||||
case priority
|
||||
case expiresinms = "expiresInMs"
|
||||
case wake
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let queued: [String: AnyCodable]
|
||||
public let waketriggered: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
queued: [String: AnyCodable],
|
||||
waketriggered: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.queued = queued
|
||||
self.waketriggered = waketriggered
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case queued
|
||||
case waketriggered = "wakeTriggered"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
@@ -3243,6 +3353,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let systeminputprovenance: [String: AnyCodable]?
|
||||
public let systemprovenancereceipt: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -3252,6 +3364,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
@@ -3260,6 +3374,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
@@ -3270,6 +3386,8 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case deliver
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,24 @@ private actor SeqGapProbe {
|
||||
}
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
|
||||
activeURL: URL(string: "wss://gateway.example.com")!)
|
||||
|
||||
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
|
||||
activeURL: URL(string: "wss://gateway.example.com:7443")!)
|
||||
|
||||
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
private struct TalkConfigContractFixture: Decodable {
|
||||
let selectionCases: [SelectionCase]
|
||||
let timeoutCases: [TimeoutCase]
|
||||
|
||||
struct SelectionCase: Decodable {
|
||||
let id: String
|
||||
let defaultProvider: String
|
||||
let payloadValid: Bool
|
||||
let expectedSelection: ExpectedSelection?
|
||||
let talk: [String: AnyCodable]
|
||||
}
|
||||
|
||||
struct ExpectedSelection: Decodable {
|
||||
let provider: String
|
||||
let normalizedPayload: Bool
|
||||
let voiceId: String?
|
||||
let apiKey: String?
|
||||
}
|
||||
|
||||
struct TimeoutCase: Decodable {
|
||||
let id: String
|
||||
let fallback: Int
|
||||
let expectedTimeoutMs: Int
|
||||
let talk: [String: AnyCodable]
|
||||
}
|
||||
}
|
||||
|
||||
private enum TalkConfigContractFixtureLoader {
|
||||
static func load() throws -> TalkConfigContractFixture {
|
||||
let fixtureURL = try self.findFixtureURL(startingAt: URL(fileURLWithPath: #filePath))
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
return try JSONDecoder().decode(TalkConfigContractFixture.self, from: data)
|
||||
}
|
||||
|
||||
private static func findFixtureURL(startingAt fileURL: URL) throws -> URL {
|
||||
var directory = fileURL.deletingLastPathComponent()
|
||||
while directory.path != "/" {
|
||||
let candidate = directory.appendingPathComponent("test-fixtures/talk-config-contract.json")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
directory.deleteLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "TalkConfigContractFixtureLoader", code: 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkConfigContractTests {
|
||||
@Test func selectionFixtures() throws {
|
||||
for fixture in try TalkConfigContractFixtureLoader.load().selectionCases {
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
fixture.talk,
|
||||
defaultProvider: fixture.defaultProvider)
|
||||
if let expected = fixture.expectedSelection {
|
||||
#expect(selection != nil)
|
||||
#expect(selection?.provider == expected.provider)
|
||||
#expect(selection?.normalizedPayload == expected.normalizedPayload)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == expected.voiceId)
|
||||
#expect(selection?.config["apiKey"]?.stringValue == expected.apiKey)
|
||||
} else {
|
||||
#expect(selection == nil)
|
||||
}
|
||||
#expect(fixture.payloadValid == (selection != nil))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func timeoutFixtures() throws {
|
||||
for fixture in try TalkConfigContractFixtureLoader.load().timeoutCases {
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
fixture.talk,
|
||||
fallback: fixture.fallback) == fixture.expectedTimeoutMs,
|
||||
"\(fixture.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
struct TalkConfigParsingTests {
|
||||
@Test func prefersCanonicalResolvedTalkProviderPayload() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"resolved": AnyCodable([
|
||||
"provider": "elevenlabs",
|
||||
"config": [
|
||||
"voiceId": "voice-resolved",
|
||||
],
|
||||
]),
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == true)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-resolved")
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
"apiKey": AnyCodable("legacy-key"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == false)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
|
||||
}
|
||||
|
||||
@Test func canDisableLegacyFallback() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedPayloadWhenProviderMissingFromProviders() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("acme"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedPayloadWhenMultipleProvidersAndNoProvider() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"providers": AnyCodable([
|
||||
"acme": [
|
||||
"voiceId": "voice-acme",
|
||||
],
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-eleven",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func bridgesFoundationDictionary() {
|
||||
let raw: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let bridged = TalkConfigParsing.bridgeFoundationDictionary(raw)
|
||||
#expect(bridged?["provider"]?.stringValue == "elevenlabs")
|
||||
let nested = bridged?["providers"]?.dictionaryValue?["elevenlabs"]?.dictionaryValue
|
||||
#expect(nested?["voiceId"]?.stringValue == "voice-normalized")
|
||||
}
|
||||
|
||||
@Test func resolvesPositiveIntegerTimeout() {
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(1500), fallback: 700) == 1500)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(0), fallback: 700) == 700)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(true), fallback: 700) == 700)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable("1500"), fallback: 700) == 700)
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,19 @@ export function isRetryableReconnectError(err) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isMissingTabError(err) {
|
||||
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
|
||||
return (
|
||||
message.includes("no tab with id") ||
|
||||
message.includes("no tab with given id") ||
|
||||
message.includes("tab not found")
|
||||
);
|
||||
}
|
||||
|
||||
export function isLastRemainingTab(allTabs, tabIdToClose) {
|
||||
if (!Array.isArray(allTabs)) {
|
||||
return true;
|
||||
}
|
||||
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
||||
import {
|
||||
buildRelayWsUrl,
|
||||
isLastRemainingTab,
|
||||
isMissingTabError,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} from './background-utils.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
@@ -41,6 +47,9 @@ const reattachPending = new Set()
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer = null
|
||||
|
||||
const TAB_VALIDATION_ATTEMPTS = 2
|
||||
const TAB_VALIDATION_RETRY_DELAY_MS = 1000
|
||||
|
||||
function nowStack() {
|
||||
try {
|
||||
return new Error().stack || ''
|
||||
@@ -49,6 +58,37 @@ function nowStack() {
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function validateAttachedTab(tabId) {
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
if (isMissingTabError(err)) {
|
||||
return false
|
||||
}
|
||||
if (attempt < TAB_VALIDATION_ATTEMPTS - 1) {
|
||||
await sleep(TAB_VALIDATION_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function getRelayPort() {
|
||||
const stored = await chrome.storage.local.get(['relayPort'])
|
||||
const raw = stored.relayPort
|
||||
@@ -108,15 +148,11 @@ async function rehydrateState() {
|
||||
tabBySession.set(entry.sessionId, entry.tabId)
|
||||
setBadge(entry.tabId, 'on')
|
||||
}
|
||||
// Phase 2: validate asynchronously, remove dead tabs.
|
||||
// Retry once so transient busy/navigation states do not permanently drop
|
||||
// a still-attached tab after a service worker restart.
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await chrome.tabs.get(entry.tabId)
|
||||
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
const valid = await validateAttachedTab(entry.tabId)
|
||||
if (!valid) {
|
||||
tabs.delete(entry.tabId)
|
||||
tabBySession.delete(entry.sessionId)
|
||||
setBadge(entry.tabId, 'off')
|
||||
@@ -259,13 +295,10 @@ async function reannounceAttachedTabs() {
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
||||
|
||||
// Verify debugger is still attached.
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
// Retry once here as well; reconnect races can briefly make an otherwise
|
||||
// healthy tab look unavailable.
|
||||
const valid = await validateAttachedTab(tabId)
|
||||
if (!valid) {
|
||||
tabs.delete(tabId)
|
||||
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
||||
setBadge(tabId, 'off')
|
||||
@@ -672,6 +705,11 @@ async function handleForwardCdpCommand(msg) {
|
||||
const toClose = target ? getTabByTargetId(target) : tabId
|
||||
if (!toClose) return { success: false }
|
||||
try {
|
||||
const allTabs = await chrome.tabs.query({})
|
||||
if (isLastRemainingTab(allTabs, toClose)) {
|
||||
console.warn('Refusing to close the last tab: this would kill the browser process')
|
||||
return { success: false, error: 'Cannot close the last tab' }
|
||||
}
|
||||
await chrome.tabs.remove(toClose)
|
||||
} catch {
|
||||
return { success: false }
|
||||
|
||||
49
docs.acp.md
49
docs.acp.md
@@ -17,6 +17,51 @@ Key goals:
|
||||
- Works with existing Gateway session store (list/resolve/reset).
|
||||
- Safe defaults (isolated ACP session keys by default).
|
||||
|
||||
## Bridge Scope
|
||||
|
||||
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
|
||||
session with predictable session mapping and basic streaming updates.
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| ACP area | Status | Notes |
|
||||
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
|
||||
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
|
||||
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
|
||||
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
|
||||
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `loadSession` replays stored user and assistant text history, but it does not
|
||||
reconstruct historic tool calls, system notices, or richer ACP-native event
|
||||
types.
|
||||
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||
turns.
|
||||
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||
less expressive than a fully ACP-native runtime.
|
||||
- Initial session controls currently surface a focused subset of Gateway knobs:
|
||||
thought level, tool verbosity, reasoning, usage detail, and elevated
|
||||
actions. Model selection and exec-host controls are not yet exposed as ACP
|
||||
config options.
|
||||
- `session_info_update` and `usage_update` are derived from Gateway session
|
||||
snapshots, not live ACP-native runtime accounting. Usage is approximate,
|
||||
carries no cost data, and is only emitted when the Gateway marks total token
|
||||
data as fresh.
|
||||
- Tool follow-along data is best-effort. The bridge can surface file paths that
|
||||
appear in known tool args/results, but it does not yet emit ACP terminals or
|
||||
structured file diffs.
|
||||
|
||||
## How can I use this
|
||||
|
||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||
@@ -181,9 +226,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
|
||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
|
||||
them. Configure MCP at the Gateway or agent layer.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
|
||||
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
|
||||
- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them.
|
||||
|
||||
## Quick start (actionable)
|
||||
|
||||
@@ -261,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
|
||||
Target format reminders:
|
||||
|
||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
|
||||
- Telegram topics should use the `:topic:` form (see below).
|
||||
|
||||
#### Telegram delivery targets (topics / forum threads)
|
||||
@@ -620,6 +622,8 @@ openclaw cron run <jobId>
|
||||
openclaw cron run <jobId> --due
|
||||
```
|
||||
|
||||
`cron.run` now acknowledges once the manual run is queued, not after the job finishes. Successful queue responses look like `{ ok: true, enqueued: true, runId }`. If the job is already running or `--due` finds nothing due, the response stays `{ ok: true, ran: false, reason }`. Use `openclaw cron runs --id <jobId>` or the `cron.runs` gateway method to inspect the eventual finished entry.
|
||||
|
||||
Edit an existing job (patch fields):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,13 +8,13 @@ title: "Brave Search"
|
||||
|
||||
# Brave Search API
|
||||
|
||||
OpenClaw supports Brave Search as a web search provider for `web_search`.
|
||||
OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/)
|
||||
2. In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||
3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
2. In the dashboard, choose the **Search** plan and generate an API key.
|
||||
3. Store the key in config or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
|
||||
@@ -72,9 +72,9 @@ await web_search({
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides paid plans; check the Brave API portal for current limits.
|
||||
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
|
||||
- OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits.
|
||||
- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans.
|
||||
- The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service).
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -12,20 +12,18 @@ Feishu (Lark) is a team chat platform used by companies for messaging and collab
|
||||
|
||||
---
|
||||
|
||||
## Plugin required
|
||||
## Bundled plugin
|
||||
|
||||
Install the Feishu plugin:
|
||||
Feishu ships bundled with current OpenClaw releases, so no separate plugin install
|
||||
is required.
|
||||
|
||||
If you are using an older build or a custom install that does not include bundled
|
||||
Feishu, install it manually:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/feishu
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/feishu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks:
|
||||
- `user:<id>` for a DM
|
||||
- `@username` for a DM (resolved via the Mattermost API)
|
||||
|
||||
Bare IDs are treated as channels.
|
||||
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
|
||||
|
||||
OpenClaw resolves them **user-first**:
|
||||
|
||||
- If the ID exists as a user (`GET /api/v4/users/<id>` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`.
|
||||
- Otherwise the ID is treated as a **channel ID**.
|
||||
|
||||
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
|
||||
|
||||
## Reactions (message tool)
|
||||
|
||||
|
||||
@@ -232,10 +232,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Live stream preview (native drafts + message edits)">
|
||||
<Accordion title="Live stream preview (message edits)">
|
||||
OpenClaw can stream partial replies in real time:
|
||||
|
||||
- direct chats: Telegram native draft streaming via `sendMessageDraft`
|
||||
- direct chats: preview message + `editMessageText`
|
||||
- groups/topics: preview message + `editMessageText`
|
||||
|
||||
Requirement:
|
||||
@@ -244,11 +244,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
|
||||
|
||||
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
|
||||
|
||||
For text-only replies:
|
||||
|
||||
- DM: OpenClaw updates the draft in place (no extra preview message)
|
||||
- DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
|
||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||
@@ -762,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Exec approvals in Telegram">
|
||||
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
|
||||
|
||||
Config path:
|
||||
|
||||
- `channels.telegram.execApprovals.enabled`
|
||||
- `channels.telegram.execApprovals.approvers`
|
||||
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`
|
||||
|
||||
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
- `target: "dm"` sends approval prompts only to configured approver DMs
|
||||
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
|
||||
- `target: "both"` sends to approver DMs and the originating chat/topic
|
||||
|
||||
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
|
||||
|
||||
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
|
||||
|
||||
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
|
||||
|
||||
Related docs: [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Troubleshooting
|
||||
@@ -861,10 +887,16 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
|
||||
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
|
||||
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
|
||||
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
|
||||
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
|
||||
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
@@ -872,7 +904,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). Telegram preview streaming uses a single preview message that is edited in place.
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
@@ -896,6 +928,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
|
||||
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
|
||||
@@ -13,6 +13,49 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||
runtime. It focuses on session routing, prompt delivery, and basic streaming
|
||||
updates.
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| ACP area | Status | Notes |
|
||||
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
|
||||
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
|
||||
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
|
||||
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
|
||||
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `loadSession` replays stored user and assistant text history, but it does not
|
||||
reconstruct historic tool calls, system notices, or richer ACP-native event
|
||||
types.
|
||||
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||
turns.
|
||||
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||
less expressive than a fully ACP-native runtime.
|
||||
- Initial session controls currently surface a focused subset of Gateway knobs:
|
||||
thought level, tool verbosity, reasoning, usage detail, and elevated
|
||||
actions. Model selection and exec-host controls are not yet exposed as ACP
|
||||
config options.
|
||||
- `session_info_update` and `usage_update` are derived from Gateway session
|
||||
snapshots, not live ACP-native runtime accounting. Usage is approximate,
|
||||
carries no cost data, and is only emitted when the Gateway marks total token
|
||||
data as fresh.
|
||||
- Tool follow-along data is best-effort. The bridge can surface file paths that
|
||||
appear in known tool args/results, but it does not yet emit ACP terminals or
|
||||
structured file diffs.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
@@ -96,6 +139,56 @@ Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
|
||||
sends them during `newSession` or `loadSession`, the bridge returns a clear
|
||||
error instead of silently ignoring them.
|
||||
|
||||
## Use from `acpx` (Codex, Claude, other ACP clients)
|
||||
|
||||
If you want a coding agent such as Codex or Claude Code to talk to your
|
||||
OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target.
|
||||
|
||||
Typical flow:
|
||||
|
||||
1. Run the Gateway and make sure the ACP bridge can reach it.
|
||||
2. Point `acpx openclaw` at `openclaw acp`.
|
||||
3. Target the OpenClaw session key you want the coding agent to use.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# One-shot request into your default OpenClaw ACP session
|
||||
acpx openclaw exec "Summarize the active OpenClaw session state."
|
||||
|
||||
# Persistent named session for follow-up turns
|
||||
acpx openclaw sessions ensure --name codex-bridge
|
||||
acpx openclaw -s codex-bridge --cwd /path/to/repo \
|
||||
"Ask my OpenClaw work agent for recent context relevant to this repo."
|
||||
```
|
||||
|
||||
If you want `acpx openclaw` to target a specific Gateway and session key every
|
||||
time, override the `openclaw` agent command in `~/.acpx/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"openclaw": {
|
||||
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the
|
||||
dev runner so the ACP stream stays clean. For example:
|
||||
|
||||
```bash
|
||||
env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ...
|
||||
```
|
||||
|
||||
This is the easiest way to let Codex, Claude Code, or another ACP-aware client
|
||||
pull contextual information from an OpenClaw agent without scraping a terminal.
|
||||
|
||||
## Zed editor setup
|
||||
|
||||
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||
|
||||
76
docs/cli/backup.md
Normal file
76
docs/cli/backup.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw backup` (create local backup archives)"
|
||||
read_when:
|
||||
- You want a first-class backup archive for local OpenClaw state
|
||||
- You want to preview which paths would be included before reset or uninstall
|
||||
title: "backup"
|
||||
---
|
||||
|
||||
# `openclaw backup`
|
||||
|
||||
Create a local backup archive for OpenClaw state, config, credentials, sessions, and optionally workspaces.
|
||||
|
||||
```bash
|
||||
openclaw backup create
|
||||
openclaw backup create --output ~/Backups
|
||||
openclaw backup create --dry-run --json
|
||||
openclaw backup create --verify
|
||||
openclaw backup create --no-include-workspace
|
||||
openclaw backup create --only-config
|
||||
openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The archive includes a `manifest.json` file with the resolved source paths and archive layout.
|
||||
- Default output is a timestamped `.tar.gz` archive in the current working directory.
|
||||
- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location.
|
||||
- Existing archive files are never overwritten.
|
||||
- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion.
|
||||
- `openclaw backup verify <archive>` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball.
|
||||
- `openclaw backup create --verify` runs that validation immediately after writing the archive.
|
||||
- `openclaw backup create --only-config` backs up just the active JSON config file.
|
||||
|
||||
## What gets backed up
|
||||
|
||||
`openclaw backup create` plans backup sources from your local OpenClaw install:
|
||||
|
||||
- The state directory returned by OpenClaw's local state resolver, usually `~/.openclaw`
|
||||
- The active config file path
|
||||
- The OAuth / credentials directory
|
||||
- Workspace directories discovered from the current config, unless you pass `--no-include-workspace`
|
||||
|
||||
If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path.
|
||||
|
||||
OpenClaw canonicalizes paths before building the archive. If config, credentials, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped.
|
||||
|
||||
The archive payload stores file contents from those source trees, and the embedded `manifest.json` records the resolved absolute source paths plus the archive layout used for each asset.
|
||||
|
||||
## Invalid config behavior
|
||||
|
||||
`openclaw backup` intentionally bypasses the normal config preflight so it can still help during recovery. Because workspace discovery depends on a valid config, `openclaw backup create` now fails fast when the config file exists but is invalid and workspace backup is still enabled.
|
||||
|
||||
If you still want a partial backup in that situation, rerun:
|
||||
|
||||
```bash
|
||||
openclaw backup create --no-include-workspace
|
||||
```
|
||||
|
||||
That keeps state, config, and credentials in scope while skipping workspace discovery entirely.
|
||||
|
||||
If you only need a copy of the config file itself, `--only-config` also works when the config is malformed because it does not rely on parsing the config for workspace discovery.
|
||||
|
||||
## Size and performance
|
||||
|
||||
OpenClaw does not enforce a built-in maximum backup size or per-file size limit.
|
||||
|
||||
Practical limits come from the local machine and destination filesystem:
|
||||
|
||||
- Available space for the temporary archive write plus the final archive
|
||||
- Time to walk large workspace trees and compress them into a `.tar.gz`
|
||||
- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify`
|
||||
- Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported
|
||||
|
||||
Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`.
|
||||
|
||||
For the smallest archive, use `--only-config`.
|
||||
@@ -23,11 +23,19 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-
|
||||
|
||||
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
|
||||
|
||||
Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id <job-id>` to follow the eventual outcome.
|
||||
|
||||
Note: retention/pruning is controlled in config:
|
||||
|
||||
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
|
||||
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
|
||||
|
||||
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
|
||||
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
|
||||
top-level delivery fields, payload `provider` delivery aliases) and migrates simple
|
||||
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
|
||||
configured.
|
||||
|
||||
## Common edits
|
||||
|
||||
Update delivery settings without changing the message:
|
||||
|
||||
@@ -28,6 +28,7 @@ Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`completion`](/cli/completion)
|
||||
- [`doctor`](/cli/doctor)
|
||||
- [`dashboard`](/cli/dashboard)
|
||||
- [`backup`](/cli/backup)
|
||||
- [`reset`](/cli/reset)
|
||||
- [`uninstall`](/cli/uninstall)
|
||||
- [`update`](/cli/update)
|
||||
@@ -103,6 +104,9 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
completion
|
||||
doctor
|
||||
dashboard
|
||||
backup
|
||||
create
|
||||
verify
|
||||
security
|
||||
audit
|
||||
secrets
|
||||
|
||||
@@ -11,7 +11,10 @@ title: "reset"
|
||||
Reset local config/state (keeps the CLI installed).
|
||||
|
||||
```bash
|
||||
openclaw backup create
|
||||
openclaw reset
|
||||
openclaw reset --dry-run
|
||||
openclaw reset --scope config+creds+sessions --yes --non-interactive
|
||||
```
|
||||
|
||||
Run `openclaw backup create` first if you want a restorable snapshot before removing local state.
|
||||
|
||||
@@ -11,7 +11,10 @@ title: "uninstall"
|
||||
Uninstall the gateway service + local data (CLI remains).
|
||||
|
||||
```bash
|
||||
openclaw backup create
|
||||
openclaw uninstall
|
||||
openclaw uninstall --all --yes
|
||||
openclaw uninstall --dry-run
|
||||
```
|
||||
|
||||
Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces.
|
||||
|
||||
@@ -24,6 +24,36 @@ Compaction **persists** in the session’s JSONL history.
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "openrouter/anthropic/claude-sonnet-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "ollama/llama3.1:8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When unset, compaction uses the agent's primary model.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
When a session nears or exceeds the model’s context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
@@ -138,7 +138,7 @@ Legacy key migration:
|
||||
|
||||
Telegram:
|
||||
|
||||
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
|
||||
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to preview.
|
||||
|
||||
|
||||
@@ -1013,7 +1013,8 @@
|
||||
"tools/browser",
|
||||
"tools/browser-login",
|
||||
"tools/chrome-extension",
|
||||
"tools/browser-linux-troubleshooting"
|
||||
"tools/browser-linux-troubleshooting",
|
||||
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1005,6 +1005,7 @@ Periodic heartbeat runs.
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
|
||||
model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -1021,6 +1022,7 @@ Periodic heartbeat runs.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
@@ -1659,6 +1661,7 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
modelId: "eleven_v3",
|
||||
outputFormat: "mp3_44100_128",
|
||||
apiKey: "elevenlabs_api_key",
|
||||
silenceTimeoutMs: 1500,
|
||||
interruptOnSpeech: true,
|
||||
},
|
||||
}
|
||||
@@ -1668,6 +1671,7 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
- `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects.
|
||||
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
|
||||
- `voiceAliases` lets Talk directives use friendly names.
|
||||
- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`).
|
||||
|
||||
---
|
||||
|
||||
@@ -2350,6 +2354,7 @@ See [Plugins](/tools/plugin).
|
||||
// headless: false,
|
||||
// noSandbox: false,
|
||||
// extraArgs: [],
|
||||
// relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2)
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false,
|
||||
},
|
||||
@@ -2366,6 +2371,7 @@ See [Plugins](/tools/plugin).
|
||||
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
||||
`--disable-gpu`, window sizing, or debug flags).
|
||||
- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ cat ~/.openclaw/openclaw.json
|
||||
- Config normalization for legacy values.
|
||||
- OpenCode Zen provider override warnings (`models.providers.opencode`).
|
||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
||||
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
|
||||
@@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the
|
||||
per-agent path without a manual doctor run. WhatsApp auth is intentionally only
|
||||
migrated via `openclaw doctor`.
|
||||
|
||||
### 3b) Legacy cron store migrations
|
||||
|
||||
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default,
|
||||
or `cron.store` when overridden) for old job shapes that the scheduler still
|
||||
accepts for compatibility.
|
||||
|
||||
Current cron cleanups include:
|
||||
|
||||
- `jobId` → `id`
|
||||
- `schedule.cron` → `schedule.expr`
|
||||
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
|
||||
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
|
||||
- payload `provider` delivery aliases → explicit `delivery.channel`
|
||||
- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook`
|
||||
|
||||
Doctor only auto-migrates `notify: true` jobs when it can do so without
|
||||
changing behavior. If a job combines legacy notify fallback with an existing
|
||||
non-webhook delivery mode, doctor warns and leaves that job for manual review.
|
||||
|
||||
### 4) State integrity checks (session persistence, routing, and safety)
|
||||
|
||||
The state directory is the operational brainstem. If it vanishes, you lose
|
||||
|
||||
@@ -38,7 +38,8 @@ Examples of inactive surfaces:
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
|
||||
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
|
||||
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
|
||||
After selection, non-selected provider keys are treated as inactive until selected.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
|
||||
@@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes:
|
||||
These are runtime markers (not required user config). They can be used in shell/profile logic
|
||||
to apply context-specific rules.
|
||||
|
||||
## UI env vars
|
||||
|
||||
- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background.
|
||||
- `OPENCLAW_THEME=dark`: force the dark TUI palette.
|
||||
- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette.
|
||||
|
||||
## Env var substitution in config
|
||||
|
||||
You can reference env vars directly in config string values using `${VAR_NAME}` syntax:
|
||||
|
||||
@@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config:
|
||||
|
||||
### How do I enable web search and web fetch
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
key. **Recommended:** run `openclaw configure --section web` to store it in
|
||||
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
|
||||
Gateway process.
|
||||
`web_fetch` works without an API key. `web_search` requires a key for your
|
||||
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
|
||||
**Recommended:** run `openclaw configure --section web` and choose a provider.
|
||||
Environment alternatives:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1500,6 +1506,7 @@ Gateway process.
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
},
|
||||
@@ -2504,7 +2511,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
|
||||
|
||||
Facts (from code):
|
||||
|
||||
- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage.
|
||||
- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
|
||||
|
||||
Fix:
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ flowchart TD
|
||||
|
||||
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
|
||||
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
||||
- [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
|
||||
- [/tools/chrome-extension](/tools/chrome-extension)
|
||||
|
||||
</Accordion>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user