mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 16:53:02 +08:00
Compare commits
121 Commits
fix/sqlite
...
fix/sqlite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f02bbcbb3 | ||
|
|
5e1fbca3cb | ||
|
|
e949809f6e | ||
|
|
25160515e0 | ||
|
|
af5c7c5fd0 | ||
|
|
cd0bca0823 | ||
|
|
0a1cf8a776 | ||
|
|
446936d600 | ||
|
|
257b251e26 | ||
|
|
c517162536 | ||
|
|
1240de7588 | ||
|
|
93d540d67b | ||
|
|
1727ec7b2d | ||
|
|
b08e1109c6 | ||
|
|
d12b7b0551 | ||
|
|
994f4f99fe | ||
|
|
f57c3b55fd | ||
|
|
56fe1e0c95 | ||
|
|
7128fa8832 | ||
|
|
e103d1231d | ||
|
|
a1fb8cf304 | ||
|
|
ea51c3ea50 | ||
|
|
34e76e6e6f | ||
|
|
090c492759 | ||
|
|
09a5cdaca3 | ||
|
|
c93e837336 | ||
|
|
9cf46d7e5a | ||
|
|
5e4a160f54 | ||
|
|
33cac9092b | ||
|
|
0c60bad890 | ||
|
|
a172db54b4 | ||
|
|
5f13d0c817 | ||
|
|
d0a84089a0 | ||
|
|
1ba782f286 | ||
|
|
3137110167 | ||
|
|
4c98a547d0 | ||
|
|
634bcf6667 | ||
|
|
e1978cf73c | ||
|
|
7e3100a120 | ||
|
|
03a8d18cd4 | ||
|
|
6fcc945702 | ||
|
|
b3c946999d | ||
|
|
f05e9873c6 | ||
|
|
9fdd56da21 | ||
|
|
4c55dd8549 | ||
|
|
162957565a | ||
|
|
c8a8152cd7 | ||
|
|
84acb74a6a | ||
|
|
9bbde70458 | ||
|
|
099abea089 | ||
|
|
e8cf6df3a3 | ||
|
|
9210d8f7d9 | ||
|
|
c9050c982d | ||
|
|
0933726574 | ||
|
|
9f48254f09 | ||
|
|
329fa44d23 | ||
|
|
aef1fad58d | ||
|
|
82afc4678a | ||
|
|
aa935ddeb2 | ||
|
|
fff5261ade | ||
|
|
5ef0d6c693 | ||
|
|
38a11944f4 | ||
|
|
1559c16a76 | ||
|
|
280d1cb977 | ||
|
|
14b1ebd640 | ||
|
|
5097749de3 | ||
|
|
27189b3e74 | ||
|
|
79c6136a9e | ||
|
|
c4a0ca0b7a | ||
|
|
dfb44912ed | ||
|
|
80f1ae6ffe | ||
|
|
2c6bdc8b28 | ||
|
|
72e40833ba | ||
|
|
5b76436c45 | ||
|
|
9082233a43 | ||
|
|
24196e05f5 | ||
|
|
93313c95a5 | ||
|
|
be5bfdccd1 | ||
|
|
372f85d368 | ||
|
|
3cf94309d9 | ||
|
|
f45cd5e57e | ||
|
|
c13802c912 | ||
|
|
c3cdd4971b | ||
|
|
5f6ee9f913 | ||
|
|
ebb9c6a013 | ||
|
|
0df7fe3056 | ||
|
|
50130d32a9 | ||
|
|
7a0e65773a | ||
|
|
c7b01cf201 | ||
|
|
bad449301f | ||
|
|
2a611865f4 | ||
|
|
1019b591d5 | ||
|
|
ff5fac1439 | ||
|
|
f29248fa62 | ||
|
|
04b8c4f313 | ||
|
|
f1a1fce982 | ||
|
|
9faa741536 | ||
|
|
dc51c57e29 | ||
|
|
95c72dde0f | ||
|
|
37c1e2725a | ||
|
|
06b226e8b5 | ||
|
|
3ed8d5f2c3 | ||
|
|
2d5bf186c1 | ||
|
|
3a2176267c | ||
|
|
4b55a0e04d | ||
|
|
9cdf853409 | ||
|
|
cff8154954 | ||
|
|
55de547b52 | ||
|
|
505b23a137 | ||
|
|
5496044f6d | ||
|
|
20604f7a8f | ||
|
|
a0f76b2b25 | ||
|
|
fb97b3b4b3 | ||
|
|
112e98faa2 | ||
|
|
646bc0d274 | ||
|
|
c967172f69 | ||
|
|
6aa89bb5f8 | ||
|
|
a54f50a41b | ||
|
|
f9f7475dbf | ||
|
|
b875c812f7 | ||
|
|
6c4fb997e5 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -293,6 +293,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lobster/**"
|
||||
"extensions: llama-cpp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/llama-cpp/**"
|
||||
"extensions: memory-core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -2093,7 +2093,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-sdk-v1-
|
||||
|
||||
@@ -2101,7 +2101,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="12266719"
|
||||
CMDLINE_TOOLS_VERSION="14742923"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
|
||||
@@ -2123,7 +2123,7 @@ jobs:
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
@@ -46,6 +46,6 @@ jobs:
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
60
.github/workflows/codeql-critical-quality.yml
vendored
60
.github/workflows/codeql-critical-quality.yml
vendored
@@ -342,13 +342,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/core-auth-secrets"
|
||||
|
||||
@@ -365,13 +365,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/config-boundary"
|
||||
|
||||
@@ -388,13 +388,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/gateway-runtime-boundary"
|
||||
|
||||
@@ -411,13 +411,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/channel-runtime-boundary"
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
@@ -518,13 +518,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/agent-runtime-boundary"
|
||||
|
||||
@@ -541,13 +541,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
|
||||
|
||||
@@ -564,13 +564,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/memory-runtime-boundary"
|
||||
|
||||
@@ -587,13 +587,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/session-diagnostics-boundary"
|
||||
|
||||
@@ -610,13 +610,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
|
||||
|
||||
@@ -633,13 +633,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/provider-runtime-boundary"
|
||||
|
||||
@@ -655,13 +655,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/ui-control-plane"
|
||||
|
||||
@@ -677,13 +677,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/web-media-runtime-boundary"
|
||||
|
||||
@@ -700,13 +700,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
@@ -723,12 +723,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-package-contract"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -101,12 +101,12 @@ jobs:
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-security-high/${{ matrix.category }}"
|
||||
|
||||
65
.github/workflows/docker-release.yml
vendored
65
.github/workflows/docker-release.yml
vendored
@@ -88,11 +88,30 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -279,11 +298,30 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -468,7 +506,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -561,11 +599,30 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
8
.github/workflows/install-smoke.yml
vendored
8
.github/workflows/install-smoke.yml
vendored
@@ -223,7 +223,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/mantis-discord-smoke.yml
vendored
2
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
|
||||
@@ -581,7 +581,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
|
||||
@@ -603,7 +603,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "pull_request_target") {
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
8
.github/workflows/mantis-telegram-live.yml
vendored
8
.github/workflows/mantis-telegram-live.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
@@ -573,7 +573,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -1503,31 +1503,66 @@ jobs:
|
||||
|
||||
- name: Build and push bare Docker E2E image
|
||||
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: bare
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.bare_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.bare_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target bare
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Build and push functional Docker E2E image
|
||||
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: functional
|
||||
build-contexts: |
|
||||
openclaw_package=.artifacts/docker-e2e-package
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.functional_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.functional_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target functional
|
||||
--build-context openclaw_package=.artifacts/docker-e2e-package
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
prepare_live_test_image:
|
||||
needs: validate_selected_ref
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
151
.github/workflows/plugin-clawhub-release.yml
vendored
151
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -24,6 +24,11 @@ on:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the full ClawHub artifact handoff without publishing.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -35,7 +40,7 @@ env:
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
|
||||
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -56,12 +61,6 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
@@ -107,6 +106,12 @@ jobs:
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
@@ -326,15 +331,12 @@ jobs:
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
pack_plugins_clawhub_artifacts:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
@@ -407,73 +409,7 @@ jobs:
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Write ClawHub token config
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
|
||||
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
|
||||
exit 0
|
||||
fi
|
||||
node --input-type=module <<'EOF'
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
registry: process.env.CLAWHUB_REGISTRY,
|
||||
token: process.env.CLAWHUB_TOKEN,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(path);
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
|
||||
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
|
||||
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
|
||||
status=""
|
||||
for attempt in $(seq 1 8); do
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
|
||||
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
|
||||
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
|
||||
sleep 60
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
- name: Pack ClawHub package artifact
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
@@ -481,8 +417,65 @@ jobs:
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
|
||||
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
|
||||
|
||||
- name: Upload ClawHub package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.plugin.artifactName }}
|
||||
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugin_clawhub_release:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Approve ClawHub package publish
|
||||
run: echo "ClawHub package publish approved."
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
|
||||
with:
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
json: true
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
registry: https://clawhub.ai
|
||||
site: https://clawhub.ai
|
||||
source_repo: ${{ github.repository }}
|
||||
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
source_ref: ${{ github.ref }}
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify published ClawHub package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "schedule") {
|
||||
|
||||
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
|
||||
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
|
||||
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
|
||||
- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -39,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
|
||||
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
|
||||
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
|
||||
- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
|
||||
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
|
||||
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
|
||||
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
|
||||
|
||||
@@ -41,7 +41,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
|
||||
@@ -49,6 +49,19 @@ import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private fun createDnsResolver(context: Context): DnsResolver =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
createContextDnsResolver(context)
|
||||
} else {
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
|
||||
|
||||
/**
|
||||
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
|
||||
*/
|
||||
@@ -58,7 +71,7 @@ class GatewayDiscovery(
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val dns = createDnsResolver(context)
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app.benchmark"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
|
||||
@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
@@ -19,7 +19,7 @@ junit = "4.13.2"
|
||||
junit-vintage = "6.1.0"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.0"
|
||||
material = "1.14.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
|
||||
@@ -329,6 +329,13 @@ struct AgentConfigLite: Decodable {
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
let replacePaths: [String]?
|
||||
|
||||
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
|
||||
self.raw = raw
|
||||
self.baseHash = baseHash
|
||||
self.replacePaths = replacePaths
|
||||
}
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
|
||||
@@ -621,7 +621,10 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
|
||||
let params = ConfigPatchParams(
|
||||
raw: raw,
|
||||
baseHash: baseHash,
|
||||
replacePaths: ["agents.list[].skills"])
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "a88730a64ccb5fd092108256c37d6c80bc7b92a5b6b563d83a9a26988550234d",
|
||||
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -42,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"revision" : "faf843032772c2074d834b931911bf0002704136",
|
||||
"version" : "3.3.0"
|
||||
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
|
||||
"version" : "3.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.3.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../swabble"),
|
||||
],
|
||||
|
||||
@@ -2773,6 +2773,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let replacepaths: [String]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
@@ -2780,7 +2781,8 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
deliverycontext: [String: AnyCodable]?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
replacepaths: [String]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
@@ -2788,6 +2790,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
self.deliverycontext = deliverycontext
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.replacepaths = replacepaths
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2797,6 +2800,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
case deliverycontext = "deliveryContext"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case replacepaths = "replacePaths"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,16 @@ const config = {
|
||||
entry: ["index.js!", "scripts/postinstall.js!"],
|
||||
project: ["index.js!", "scripts/**/*.js!"],
|
||||
},
|
||||
[`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: {
|
||||
entry: bundledPluginEntries,
|
||||
project: ["index.ts!", "src/**/*.{js,mjs,ts}!"],
|
||||
ignoreDependencies: [
|
||||
// The provider resolves node-llama-cpp from its own package at runtime
|
||||
// so local embeddings use the plugin-owned native dependency.
|
||||
"node-llama-cpp",
|
||||
...bundledPluginIgnoredRuntimeDependencies,
|
||||
],
|
||||
},
|
||||
[`${BUNDLED_PLUGIN_ROOT_DIR}/*`]: {
|
||||
// Bundled plugins often load their public surface via string specifiers in
|
||||
// `index.ts` contracts, so Knip needs these convention-based entry files.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
de06fd99257e4b010e54578ea46605c3bc631c31cac5f68aaed4e301f924f8af plugin-sdk-api-baseline.json
|
||||
1c7a5420c4bcb1ec08544ff43b83fa4d43f3c0dcda597a5a25aa5f5bab0cb199 plugin-sdk-api-baseline.jsonl
|
||||
1cd5bcc75461c64d39a00918a50d033e66ae7ec199d8029f7cccaaa2eeb16f22 plugin-sdk-api-baseline.json
|
||||
a5d3b43c3710c4238958b1b3163e652ac34bdc7b82215c6294ce61b72188d75e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -55,14 +55,6 @@
|
||||
"source": "Mantis",
|
||||
"target": "Mantis"
|
||||
},
|
||||
{
|
||||
"source": "OpenClaw App SDK",
|
||||
"target": "OpenClaw 应用 SDK"
|
||||
},
|
||||
{
|
||||
"source": "OpenClaw App SDK API design",
|
||||
"target": "OpenClaw 应用 SDK API 设计"
|
||||
},
|
||||
{
|
||||
"source": "Message lifecycle refactor",
|
||||
"target": "消息生命周期重构"
|
||||
|
||||
@@ -39,9 +39,12 @@ To set a provider explicitly:
|
||||
|
||||
Without an embedding provider, only keyword search is available.
|
||||
|
||||
To force the built-in local embedding provider, install the optional
|
||||
`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath`
|
||||
at a GGUF file:
|
||||
To force local GGUF embeddings, install the official llama.cpp provider plugin,
|
||||
then point `local.modelPath` at a GGUF file:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/llama-cpp-provider
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -67,7 +70,7 @@ at a GGUF file:
|
||||
| DeepInfra | `deepinfra` | Default: `BAAI/bge-m3` |
|
||||
| Gemini | `gemini` | Supports multimodal (image + audio) |
|
||||
| GitHub Copilot | `github-copilot` | Uses Copilot subscription |
|
||||
| Local | `local` | Optional `node-llama-cpp` runtime |
|
||||
| Local | `local` | `@openclaw/llama-cpp-provider` |
|
||||
| Mistral | `mistral` | |
|
||||
| Ollama | `ollama` | Local/self-hosted |
|
||||
| OpenAI | `openai` | Default: `text-embedding-3-small` |
|
||||
|
||||
@@ -15,7 +15,7 @@ binary, and can index content beyond your workspace memory files.
|
||||
- **Reranking and query expansion** for better recall.
|
||||
- **Index extra directories** -- project docs, team notes, anything on disk.
|
||||
- **Index session transcripts** -- recall earlier conversations.
|
||||
- **Fully local** -- runs with the optional node-llama-cpp runtime package and
|
||||
- **Fully local** -- runs with the official llama.cpp provider plugin and
|
||||
auto-downloads GGUF models.
|
||||
- **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the
|
||||
builtin engine seamlessly.
|
||||
|
||||
@@ -32,7 +32,8 @@ For multi-endpoint setups with memory-specific providers, `provider` can also
|
||||
be a custom `models.providers.<id>` entry, such as `ollama-5080`, when that
|
||||
provider sets `api: "ollama"` or another memory embedding adapter owner.
|
||||
|
||||
For local embeddings with no API key, set `provider: "local"`. Source checkouts
|
||||
For local embeddings with no API key, install
|
||||
`@openclaw/llama-cpp-provider` and set `provider: "local"`. Source checkouts
|
||||
may still require native build approval: `pnpm approve-builds` then
|
||||
`pnpm rebuild node-llama-cpp`.
|
||||
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
---
|
||||
summary: "Public OpenClaw App SDK for external apps, scripts, dashboards, CI jobs, and IDE extensions"
|
||||
title: "OpenClaw App SDK"
|
||||
sidebarTitle: "App SDK"
|
||||
read_when:
|
||||
- You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw
|
||||
- You are choosing between the App SDK and the Plugin SDK
|
||||
- You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools
|
||||
---
|
||||
|
||||
The **OpenClaw App SDK** is the public client API for apps outside the
|
||||
OpenClaw process. Use `@openclaw/sdk` when a script, dashboard, CI job, IDE
|
||||
extension, or other external app wants to connect to the Gateway, start agent
|
||||
runs, stream events, wait for results, cancel work, or inspect Gateway
|
||||
resources.
|
||||
|
||||
<Note>
|
||||
The App SDK is different from the [Plugin SDK](/plugins/sdk-overview).
|
||||
`@openclaw/sdk` talks to the Gateway from outside OpenClaw.
|
||||
`openclaw/plugin-sdk/*` is only for plugins that run inside OpenClaw and
|
||||
register providers, channels, tools, hooks, or trusted runtimes.
|
||||
</Note>
|
||||
|
||||
## What ships today
|
||||
|
||||
`@openclaw/sdk` ships with:
|
||||
|
||||
| Surface | Status | What it does |
|
||||
| ------------------------- | ------- | --------------------------------------------------------------------------------- |
|
||||
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
|
||||
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
|
||||
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
|
||||
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
|
||||
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
|
||||
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
|
||||
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
|
||||
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
|
||||
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
|
||||
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
|
||||
| `oc.tasks` | Ready | Lists, reads, and cancels Gateway task ledger entries. |
|
||||
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
|
||||
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
|
||||
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
|
||||
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
|
||||
| `oc.environments` | Partial | Lists Gateway-local and node environment candidates; create/delete are not wired. |
|
||||
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
|
||||
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
|
||||
|
||||
The SDK also exports the core types used by those surfaces:
|
||||
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
|
||||
`OpenClawEventType`, `GatewayEvent`, `OpenClawTransport`,
|
||||
`GatewayRequestOptions`, `SessionCreateParams`, `SessionSendParams`,
|
||||
`ArtifactSummary`, `ArtifactQuery`, `ArtifactsListResult`,
|
||||
`ArtifactsGetResult`, `ArtifactsDownloadResult`,
|
||||
`TaskSummary`, `TaskStatus`, `TasksListParams`, `TasksListResult`,
|
||||
`TasksGetResult`, `TasksCancelResult`, `RuntimeSelection`,
|
||||
`EnvironmentSelection`, `WorkspaceSelection`, `ApprovalMode`, and related
|
||||
result types.
|
||||
|
||||
## Connect to a Gateway
|
||||
|
||||
Create a client with an explicit Gateway URL, or inject a custom transport for
|
||||
tests and embedded app runtimes.
|
||||
|
||||
```typescript
|
||||
import { OpenClaw } from "@openclaw/sdk";
|
||||
|
||||
const oc = new OpenClaw({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
requestTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
await oc.connect();
|
||||
```
|
||||
|
||||
`new OpenClaw({ gateway: "ws://..." })` is equivalent to `url`. The
|
||||
`gateway: "auto"` option is accepted by the constructor, but automatic Gateway
|
||||
discovery is not a separate SDK feature yet; pass `url` when the app does not
|
||||
already know how to discover the Gateway.
|
||||
|
||||
For tests, pass an object that implements `OpenClawTransport`:
|
||||
|
||||
```typescript
|
||||
const oc = new OpenClaw({
|
||||
transport: {
|
||||
async request(method, params) {
|
||||
return { method, params };
|
||||
},
|
||||
async *events() {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Run an agent
|
||||
|
||||
Use `oc.agents.get(id)` when the app wants an agent handle, then call
|
||||
`agent.run()`.
|
||||
|
||||
```typescript
|
||||
const agent = await oc.agents.get("main");
|
||||
|
||||
const run = await agent.run({
|
||||
input: "Review this pull request and suggest the smallest safe fix.",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: "main",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
for await (const event of run.events()) {
|
||||
const data = event.data as { delta?: unknown };
|
||||
if (event.type === "assistant.delta" && typeof data.delta === "string") {
|
||||
process.stdout.write(data.delta);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await run.wait({ timeoutMs: 120_000 });
|
||||
console.log(result.status);
|
||||
```
|
||||
|
||||
Provider-qualified model refs such as `openai/gpt-5.5` are split into Gateway
|
||||
`provider` and `model` overrides. `timeoutMs` stays milliseconds in the SDK and
|
||||
is converted to Gateway timeout seconds for the `agent` RPC.
|
||||
|
||||
`run.wait()` uses the Gateway `agent.wait` RPC. A wait deadline that expires
|
||||
while the run is still active returns `status: "accepted"` instead of pretending
|
||||
the run itself timed out. Runtime timeouts, aborted runs, and cancelled runs are
|
||||
normalized into `timed_out` or `cancelled`.
|
||||
|
||||
## Create and reuse sessions
|
||||
|
||||
Use sessions when the app wants durable transcript state.
|
||||
|
||||
```typescript
|
||||
const session = await oc.sessions.create({
|
||||
agentId: "main",
|
||||
label: "release-review",
|
||||
});
|
||||
|
||||
const run = await session.send("Prepare release notes from the current diff.");
|
||||
await run.wait();
|
||||
```
|
||||
|
||||
`Session.send()` calls `sessions.send` and returns a `Run`. Session handles also
|
||||
support:
|
||||
|
||||
```typescript
|
||||
await session.abort(run.id);
|
||||
await session.patch({ label: "renamed-session" });
|
||||
await session.compact({ maxLines: 200 });
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
The SDK normalizes raw Gateway events into a stable `OpenClawEvent` envelope:
|
||||
|
||||
```typescript
|
||||
type OpenClawEvent = {
|
||||
version: 1;
|
||||
id: string;
|
||||
ts: number;
|
||||
type: OpenClawEventType;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
data: unknown;
|
||||
raw?: GatewayEvent;
|
||||
};
|
||||
```
|
||||
|
||||
Common event types include:
|
||||
|
||||
| Event type | Source Gateway event |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| `run.started` | `agent` lifecycle start |
|
||||
| `run.completed` | `agent` lifecycle end |
|
||||
| `run.failed` | `agent` lifecycle error |
|
||||
| `run.cancelled` | Aborted/cancelled lifecycle end |
|
||||
| `run.timed_out` | Timeout lifecycle end |
|
||||
| `assistant.delta` | Assistant streaming delta |
|
||||
| `assistant.message` | Assistant message |
|
||||
| `thinking.delta` | Thinking or plan stream |
|
||||
| `tool.call.started` | Tool/item/command start |
|
||||
| `tool.call.delta` | Tool/item/command update |
|
||||
| `tool.call.completed` | Tool/item/command completion |
|
||||
| `tool.call.failed` | Tool/item/command failure or blocked status |
|
||||
| `approval.requested` | Exec or plugin approval request |
|
||||
| `approval.resolved` | Exec or plugin approval resolution |
|
||||
| `session.created` | `sessions.changed` create |
|
||||
| `session.updated` | `sessions.changed` update |
|
||||
| `session.compacted` | `sessions.changed` compaction |
|
||||
| `task.updated` | Task update events |
|
||||
| `artifact.updated` | Patch stream events |
|
||||
| `raw` | Any event without a stable SDK mapping yet |
|
||||
|
||||
`Run.events()` filters events to one run id and replays already-seen events for
|
||||
fast runs. That means the documented flow is safe:
|
||||
|
||||
```typescript
|
||||
const run = await agent.run("Summarize the latest session.");
|
||||
|
||||
for await (const event of run.events()) {
|
||||
if (event.type === "run.completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For app-wide streams, use `oc.events()`. For raw Gateway frames, use
|
||||
`oc.rawEvents()`.
|
||||
|
||||
## Models, tools, artifacts, and approvals
|
||||
|
||||
Model helpers map to current Gateway methods:
|
||||
|
||||
```typescript
|
||||
await oc.models.list();
|
||||
await oc.models.status({ probe: false }); // calls models.authStatus
|
||||
```
|
||||
|
||||
Tool helpers expose the Gateway catalog, effective tool view, and direct
|
||||
Gateway tool invocation. `oc.tools.invoke()` returns a typed envelope instead
|
||||
of throwing for policy or approval refusals.
|
||||
|
||||
```typescript
|
||||
await oc.tools.list();
|
||||
await oc.tools.effective({ sessionKey: "main" });
|
||||
await oc.tools.invoke("tool-name", {
|
||||
args: { input: "value" },
|
||||
sessionKey: "main",
|
||||
confirm: false,
|
||||
idempotencyKey: "tool-call-1",
|
||||
});
|
||||
```
|
||||
|
||||
Artifact helpers expose the Gateway artifact projection for session, run, or
|
||||
task context. Each call requires one explicit `sessionKey`, `runId`, or
|
||||
`taskId` scope:
|
||||
|
||||
```typescript
|
||||
const { artifacts } = await oc.artifacts.list({ sessionKey: "main" });
|
||||
const first = artifacts[0];
|
||||
|
||||
if (first) {
|
||||
const { artifact } = await oc.artifacts.get(first.id, { sessionKey: "main" });
|
||||
const download = await oc.artifacts.download(artifact.id, { sessionKey: "main" });
|
||||
console.log(download.encoding, download.url);
|
||||
}
|
||||
```
|
||||
|
||||
Approval helpers use the exec approval RPCs:
|
||||
|
||||
```typescript
|
||||
const approvals = await oc.approvals.list();
|
||||
await oc.approvals.respond("approval-id", { decision: "approve" });
|
||||
```
|
||||
|
||||
Task helpers use the durable task ledger that also backs `openclaw tasks`:
|
||||
|
||||
```typescript
|
||||
const tasks = await oc.tasks.list({ status: "running", sessionKey: "agent:main:main" });
|
||||
const task = await oc.tasks.get(tasks.tasks[0].id);
|
||||
await oc.tasks.cancel(task.task.id, { reason: "user stopped task" });
|
||||
```
|
||||
|
||||
Environment helpers expose read-only Gateway-local and node discovery:
|
||||
|
||||
```typescript
|
||||
const { environments } = await oc.environments.list();
|
||||
await oc.environments.status(environments[0].id);
|
||||
```
|
||||
|
||||
## Explicitly unsupported today
|
||||
|
||||
The SDK includes names for the product model we want, but it does not silently
|
||||
pretend Gateway RPCs exist. These calls currently throw explicit unsupported
|
||||
errors:
|
||||
|
||||
```typescript
|
||||
await oc.environments.create({});
|
||||
await oc.environments.delete("environment-id");
|
||||
```
|
||||
|
||||
Per-run `workspace`, `runtime`, `environment`, and `approvals` fields are typed
|
||||
as future shape, but the current Gateway does not support those overrides on
|
||||
the `agent` RPC. If callers pass them, the SDK throws before submitting the run
|
||||
so work does not accidentally execute with default workspace, runtime,
|
||||
environment, or approval behavior.
|
||||
|
||||
## App SDK vs Plugin SDK
|
||||
|
||||
Use the App SDK when code lives outside OpenClaw:
|
||||
|
||||
- Node scripts that start or observe agent runs
|
||||
- CI jobs that call a Gateway
|
||||
- dashboards and admin panels
|
||||
- IDE extensions
|
||||
- external bridges that do not need to become channel plugins
|
||||
- integration tests with fake or real Gateway transports
|
||||
|
||||
Use the Plugin SDK when code runs inside OpenClaw:
|
||||
|
||||
- provider plugins
|
||||
- channel plugins
|
||||
- tool or lifecycle hooks
|
||||
- agent harness plugins
|
||||
- trusted runtime helpers
|
||||
|
||||
App SDK code should import from `@openclaw/sdk`. Plugin code should import from
|
||||
documented `openclaw/plugin-sdk/*` subpaths. Do not mix the two contracts.
|
||||
|
||||
## Related
|
||||
|
||||
- [OpenClaw App SDK API design](/reference/openclaw-sdk-api-design)
|
||||
- [Gateway RPC reference](/reference/rpc)
|
||||
- [Agent loop](/concepts/agent-loop)
|
||||
- [Agent runtimes](/concepts/agent-runtimes)
|
||||
- [Sessions](/concepts/session)
|
||||
- [Background tasks](/automation/tasks)
|
||||
- [ACP agents](/tools/acp-agents)
|
||||
- [Plugin SDK overview](/plugins/sdk-overview)
|
||||
@@ -60,6 +60,14 @@
|
||||
"source": "/install/migrating-matrix",
|
||||
"destination": "/channels/matrix-migration"
|
||||
},
|
||||
{
|
||||
"source": "/concepts/openclaw-sdk",
|
||||
"destination": "/gateway/external-apps"
|
||||
},
|
||||
{
|
||||
"source": "/reference/openclaw-sdk-api-design",
|
||||
"destination": "/gateway/external-apps"
|
||||
},
|
||||
{
|
||||
"source": "/mcp",
|
||||
"destination": "/cli/mcp"
|
||||
@@ -1241,6 +1249,7 @@
|
||||
"plugins/admin-http-rpc",
|
||||
"plugins/voice-call",
|
||||
"plugins/memory-wiki",
|
||||
"plugins/llama-cpp",
|
||||
"plugins/memory-lancedb",
|
||||
"plugins/oc-path",
|
||||
"plugins/zalouser"
|
||||
@@ -1741,8 +1750,7 @@
|
||||
"group": "RPC and API",
|
||||
"pages": [
|
||||
"reference/rpc",
|
||||
"concepts/openclaw-sdk",
|
||||
"reference/openclaw-sdk-api-design",
|
||||
"gateway/external-apps",
|
||||
"reference/code-mode",
|
||||
"reference/device-models"
|
||||
]
|
||||
|
||||
@@ -601,7 +601,8 @@ For tooling that writes config over the gateway API, prefer this flow:
|
||||
summaries)
|
||||
- `config.get` to fetch the current snapshot plus `hash`
|
||||
- `config.patch` for partial updates (JSON merge patch: objects merge, `null`
|
||||
deletes, arrays replace)
|
||||
deletes, arrays replace when explicitly confirmed with `replacePaths` if
|
||||
entries would be removed)
|
||||
- `config.apply` only when you intend to replace the entire config
|
||||
- `update.run` for explicit self-update plus restart; include `continuationMessage` when the post-restart session should run one follow-up turn
|
||||
- `update.status` to inspect the latest update restart sentinel and verify the running version after a restart
|
||||
@@ -633,6 +634,14 @@ Both `config.apply` and `config.patch` accept `raw`, `baseHash`, `sessionKey`,
|
||||
`note`, and `restartDelayMs`. `baseHash` is required for both methods when a
|
||||
config already exists.
|
||||
|
||||
`config.patch` also accepts `replacePaths`, an array of config paths whose array
|
||||
replacement is intentional. If a patch would replace or delete an existing array
|
||||
with fewer entries, the Gateway rejects the write unless that exact path appears
|
||||
in `replacePaths`; nested arrays under array entries use `[]`, such as
|
||||
`agents.list[].skills`. This prevents truncated `config.get` snapshots from
|
||||
silently clobbering routing or allowlist arrays. Use `config.apply` when you
|
||||
intend to replace the full config.
|
||||
|
||||
## Environment variables
|
||||
|
||||
OpenClaw reads env vars from the parent process plus:
|
||||
|
||||
86
docs/gateway/external-apps.md
Normal file
86
docs/gateway/external-apps.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
summary: "Current integration path for external apps, scripts, dashboards, CI jobs, and IDE extensions"
|
||||
title: "Gateway integrations for external apps"
|
||||
sidebarTitle: "External apps"
|
||||
read_when:
|
||||
- You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw
|
||||
- You are choosing between Gateway RPC and the Plugin SDK
|
||||
- You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools
|
||||
---
|
||||
|
||||
External apps should talk to OpenClaw through the Gateway protocol today. Use
|
||||
Gateway WebSocket and RPC methods when a script, dashboard, CI job, IDE
|
||||
extension, or another process wants to start agent runs, stream events, wait for
|
||||
results, cancel work, or inspect Gateway resources.
|
||||
|
||||
<Warning>
|
||||
There is no public npm client package yet. Do not add OpenClaw client package
|
||||
names as application dependencies until release notes announce a published
|
||||
package and this page includes install instructions.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
This page is for code outside the OpenClaw process. Plugin code that runs
|
||||
inside OpenClaw should use documented `openclaw/plugin-sdk/*` subpaths instead.
|
||||
</Note>
|
||||
|
||||
## What is available today
|
||||
|
||||
| Surface | Status | Use it for |
|
||||
| --------------------------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||
| [Gateway protocol](/gateway/protocol) | Ready | WebSocket transport, connect handshake, auth scopes, protocol versioning, and events. |
|
||||
| [Gateway RPC reference](/reference/rpc) | Ready | Current Gateway methods for agents, sessions, tasks, models, tools, artifacts, and approvals. |
|
||||
| [`openclaw agent`](/cli/agent) | Ready | One-shot script integration when shelling out to the CLI is enough. |
|
||||
| [`openclaw message`](/cli/message) | Ready | Sending messages or channel actions from scripts. |
|
||||
|
||||
The source tree contains internal package work for a future client library, but
|
||||
that is not a public install surface. Treat it as preview implementation detail
|
||||
until the packages are published and versioned.
|
||||
|
||||
## Recommended path
|
||||
|
||||
1. Run or discover a Gateway.
|
||||
2. Connect over the [Gateway protocol](/gateway/protocol).
|
||||
3. Call documented RPC methods from [Gateway RPC reference](/reference/rpc).
|
||||
4. Pin the OpenClaw version you test against.
|
||||
5. Recheck the RPC reference when upgrading OpenClaw.
|
||||
|
||||
For agent runs, start with the `agent` RPC and pair it with `agent.wait` when
|
||||
you need a terminal result. For durable conversation state, use the `sessions.*`
|
||||
methods. For UI integrations, subscribe to Gateway events and render only the
|
||||
event families your app understands.
|
||||
|
||||
## App code vs plugin code
|
||||
|
||||
Use Gateway RPC when code lives outside OpenClaw:
|
||||
|
||||
- Node scripts that start or observe agent runs
|
||||
- CI jobs that call a Gateway
|
||||
- dashboards and admin panels
|
||||
- IDE extensions
|
||||
- external bridges that do not need to become channel plugins
|
||||
- integration tests with fake or real Gateway transports
|
||||
|
||||
Use the Plugin SDK when code runs inside OpenClaw:
|
||||
|
||||
- provider plugins
|
||||
- channel plugins
|
||||
- tool or lifecycle hooks
|
||||
- agent harness plugins
|
||||
- trusted runtime helpers
|
||||
|
||||
External apps should not import `openclaw/plugin-sdk/*`; those subpaths are for
|
||||
plugins loaded by OpenClaw.
|
||||
|
||||
## Related
|
||||
|
||||
- [Gateway protocol](/gateway/protocol)
|
||||
- [Gateway RPC reference](/reference/rpc)
|
||||
- [CLI agent command](/cli/agent)
|
||||
- [CLI message command](/cli/message)
|
||||
- [Agent loop](/concepts/agent-loop)
|
||||
- [Agent runtimes](/concepts/agent-runtimes)
|
||||
- [Sessions](/concepts/session)
|
||||
- [Background tasks](/automation/tasks)
|
||||
- [ACP agents](/tools/acp-agents)
|
||||
- [Plugin SDK overview](/plugins/sdk-overview)
|
||||
@@ -405,7 +405,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
|
||||
- `config.get` returns the current config snapshot and hash.
|
||||
- `config.set` writes a validated config payload.
|
||||
- `config.patch` merges a partial config update.
|
||||
- `config.patch` merges a partial config update. Destructive array
|
||||
replacement requires the affected path in `replacePaths`; nested arrays
|
||||
under array entries use `[]` paths such as `agents.list[].skills`.
|
||||
- `config.apply` validates + replaces the full config payload.
|
||||
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
|
||||
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
|
||||
|
||||
5361
docs/maturity-scores.yaml
Normal file
5361
docs/maturity-scores.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -373,17 +373,16 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
|
||||
- GPT-5.4 mini
|
||||
- GPT-5.2
|
||||
|
||||
The current bundled harness is `@openai/codex` `0.135.0`. A `model/list` probe
|
||||
The current bundled harness is `@openai/codex` `0.137.0`. A `model/list` probe
|
||||
against that bundled app-server returned:
|
||||
|
||||
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
|
||||
| --------------------- | ------- | ------ | ---------------- | ------------------------ |
|
||||
| `gpt-5.5` | Yes | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.4` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.4-mini` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.3-codex` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.3-codex-spark` | No | No | text | low, medium, high, xhigh |
|
||||
| `gpt-5.2` | No | No | text, image | low, medium, high, xhigh |
|
||||
| Model id | Default | Hidden | Input modalities | Reasoning efforts |
|
||||
| --------------- | ------- | ------ | ---------------- | ------------------------ |
|
||||
| `gpt-5.5` | Yes | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.4` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.4-mini` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.3-codex` | No | No | text, image | low, medium, high, xhigh |
|
||||
| `gpt-5.2` | No | No | text, image | low, medium, high, xhigh |
|
||||
|
||||
Hidden models can be returned by the app-server catalog for internal or
|
||||
specialized flows, but they are not normal model-picker choices.
|
||||
|
||||
58
docs/plugins/llama-cpp.md
Normal file
58
docs/plugins/llama-cpp.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
summary: "Install the official llama.cpp provider for local GGUF memory embeddings"
|
||||
read_when:
|
||||
- You want memory search embeddings from a local GGUF model
|
||||
- You are configuring memorySearch.provider = "local"
|
||||
- You need the OpenClaw plugin that owns the node-llama-cpp runtime
|
||||
title: "llama.cpp Provider"
|
||||
sidebarTitle: "llama.cpp Provider"
|
||||
---
|
||||
|
||||
`llama-cpp` is the official external provider plugin for local GGUF embeddings.
|
||||
It owns the `node-llama-cpp` runtime dependency used by
|
||||
`memorySearch.provider: "local"`.
|
||||
|
||||
Install it before using local memory embeddings:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/llama-cpp-provider
|
||||
```
|
||||
|
||||
The main `openclaw` npm package does not include `node-llama-cpp`. Keeping the
|
||||
native dependency in this plugin prevents normal OpenClaw npm updates from
|
||||
deleting a manually installed runtime inside the OpenClaw package directory.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the memory search provider to `local`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "local",
|
||||
local: {
|
||||
modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The default model is `embeddinggemma-300m-qat-Q8_0.gguf`. You can also point
|
||||
`local.modelPath` at a local `.gguf` file.
|
||||
|
||||
## Native Runtime
|
||||
|
||||
Use Node 24 for the smoothest native install path. Source checkouts using pnpm
|
||||
may need to approve and rebuild the native dependency:
|
||||
|
||||
```bash
|
||||
pnpm approve-builds
|
||||
pnpm rebuild node-llama-cpp
|
||||
```
|
||||
|
||||
For lower-friction local embeddings, use a local service provider such as
|
||||
Ollama or LM Studio instead.
|
||||
@@ -137,7 +137,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - included in OpenClaw. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
|
||||
|
||||
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds memory embedding provider support. Adds agent-callable tools.
|
||||
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds file-backed memory search tools.
|
||||
|
||||
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
|
||||
|
||||
@@ -235,7 +235,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Official external packages
|
||||
|
||||
34 plugins
|
||||
35 plugins
|
||||
|
||||
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
|
||||
|
||||
@@ -267,6 +267,8 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[googlechat](/plugins/reference/googlechat)** (`@openclaw/googlechat`) - npm; ClawHub. OpenClaw Google Chat channel plugin for spaces and direct messages.
|
||||
|
||||
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. OpenClaw llama.cpp embedding provider plugin.
|
||||
|
||||
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
|
||||
|
||||
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 126
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
23
docs/plugins/reference/llama-cpp.md
Normal file
23
docs/plugins/reference/llama-cpp.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "OpenClaw llama.cpp embedding provider plugin."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the llama-cpp plugin
|
||||
title: "llama-cpp plugin"
|
||||
---
|
||||
|
||||
# llama-cpp plugin
|
||||
|
||||
OpenClaw llama.cpp embedding provider plugin.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/llama-cpp-provider`
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
contracts: embeddingProviders
|
||||
|
||||
## Related docs
|
||||
|
||||
- [llama.cpp Provider](/plugins/llama-cpp)
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Adds memory embedding provider support. Adds agent-callable tools."
|
||||
summary: "Adds file-backed memory search tools."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the memory-core plugin
|
||||
title: "Memory Core plugin"
|
||||
@@ -7,7 +7,7 @@ title: "Memory Core plugin"
|
||||
|
||||
# Memory Core plugin
|
||||
|
||||
Adds memory embedding provider support. Adds agent-callable tools.
|
||||
Adds file-backed memory search tools.
|
||||
|
||||
## Distribution
|
||||
|
||||
@@ -16,4 +16,4 @@ Adds memory embedding provider support. Adds agent-callable tools.
|
||||
|
||||
## Surface
|
||||
|
||||
contracts: memoryEmbeddingProviders, tools
|
||||
contracts: tools
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Adds Microsoft Foundry model provider support to OpenClaw."
|
||||
summary: "Use Microsoft Foundry chat and MAI image deployments from OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the microsoft-foundry plugin
|
||||
title: "Microsoft Foundry plugin"
|
||||
@@ -7,7 +7,9 @@ title: "Microsoft Foundry plugin"
|
||||
|
||||
# Microsoft Foundry plugin
|
||||
|
||||
Adds Microsoft Foundry model provider support to OpenClaw.
|
||||
Use Microsoft Foundry deployments from OpenClaw with API-key auth or Microsoft
|
||||
Entra ID through the Azure CLI. The plugin owns Microsoft Foundry model
|
||||
discovery, runtime token refresh, and MAI image generation.
|
||||
|
||||
## Distribution
|
||||
|
||||
@@ -16,4 +18,90 @@ Adds Microsoft Foundry model provider support to OpenClaw.
|
||||
|
||||
## Surface
|
||||
|
||||
providers: microsoft-foundry
|
||||
- Model provider: `microsoft-foundry`
|
||||
- Image-generation provider: `microsoft-foundry`
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Microsoft Foundry or Azure AI Foundry resource with deployments.
|
||||
- API-key auth through `AZURE_OPENAI_API_KEY` or a configured provider API key.
|
||||
- For Entra ID auth, install the Azure CLI and run `az login` before
|
||||
onboarding. OpenClaw refreshes Microsoft Foundry runtime tokens through
|
||||
`az account get-access-token`.
|
||||
|
||||
## Chat models
|
||||
|
||||
Microsoft Foundry chat deployments use the provider model ref
|
||||
`microsoft-foundry/<deployment-name>`. Onboarding discovers Foundry resources
|
||||
and deployments with the Azure CLI, then writes the selected deployment name to
|
||||
the model config.
|
||||
|
||||
OpenClaw uses the Foundry `/openai/v1` endpoint for supported OpenAI-compatible
|
||||
chat APIs:
|
||||
|
||||
- GPT, `o*`, `computer-use-preview`, and DeepSeek-V4 model families default to
|
||||
`openai-responses`.
|
||||
- MAI-DS-R1 and other chat-completion deployments use `openai-completions`
|
||||
unless an explicit supported API is configured.
|
||||
- MAI-DS-R1 is recorded as reasoning-capable through reasoning content, not
|
||||
through `reasoning_effort`. Its context and output token metadata are
|
||||
163,840 tokens.
|
||||
|
||||
Anthropic Claude deployments in Microsoft Foundry use the Anthropic Messages
|
||||
API shape, not the OpenAI-compatible `/openai/v1` shape. Configure those as a
|
||||
custom `anthropic-messages` provider until the Microsoft Foundry plugin grows a
|
||||
native Anthropic runtime.
|
||||
|
||||
## MAI image generation
|
||||
|
||||
The plugin registers `microsoft-foundry` for `image_generate` with the current
|
||||
Microsoft AI image models:
|
||||
|
||||
- `MAI-Image-2.5-Flash`
|
||||
- `MAI-Image-2.5`
|
||||
- `MAI-Image-2e`
|
||||
- `MAI-Image-2`
|
||||
|
||||
Use a deployed MAI image deployment name as the model ref. The provider does
|
||||
not declare a default image model because the MAI API requires your deployment
|
||||
name in the request `model` field:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
imageGenerationModel: {
|
||||
primary: "microsoft-foundry/<deployment-name>",
|
||||
timeoutMs: 600000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Prompt-only generation calls Microsoft Foundry's MAI generations endpoint:
|
||||
`/mai/v1/images/generations`. Reference-image edits call
|
||||
`/mai/v1/images/edits` and are limited to `MAI-Image-2.5-Flash` and
|
||||
`MAI-Image-2.5` deployments.
|
||||
|
||||
Prompt-only generation can use a custom deployment name with just the Foundry
|
||||
endpoint configured. For image edits with a custom deployment name, select the
|
||||
deployment through onboarding or include model metadata so OpenClaw can verify
|
||||
that the deployment is backed by `MAI-Image-2.5-Flash` or `MAI-Image-2.5`.
|
||||
|
||||
MAI image constraints:
|
||||
|
||||
- Output: one PNG image per request.
|
||||
- Size: default `1024x1024`; both width and height must be at least 768 px.
|
||||
- Total pixels: width × height must be at most 1,048,576.
|
||||
- Edits: one PNG or JPEG input image.
|
||||
- Unsupported shared hints such as `aspectRatio`, `resolution`, `quality`,
|
||||
`background`, and non-PNG `outputFormat` are not sent to Microsoft Foundry.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `az: command not found`: install the Azure CLI or use API-key auth.
|
||||
- `Microsoft Foundry endpoint missing for MAI image generation`: select a
|
||||
Foundry deployment through onboarding or add `models.providers.microsoft-foundry.baseUrl`.
|
||||
- `supports MAI image deployments only`: the selected image model points at a
|
||||
non-MAI deployment. Use a deployed MAI image model for `image_generate`.
|
||||
|
||||
@@ -14,9 +14,8 @@ reference for **what to import** and **what you can register**.
|
||||
<Note>
|
||||
This page is for plugin authors using `openclaw/plugin-sdk/*` inside
|
||||
OpenClaw. For external apps, scripts, dashboards, CI jobs, and IDE extensions
|
||||
that want to run agents through the Gateway, use the
|
||||
[OpenClaw App SDK](/concepts/openclaw-sdk) and the `@openclaw/sdk` package
|
||||
instead.
|
||||
that want to run agents through the Gateway, use
|
||||
[Gateway integrations for external apps](/gateway/external-apps) instead.
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
|
||||
@@ -101,21 +101,19 @@ explicit runtime config.
|
||||
Control UI Talk with `talk.realtime.provider: "openai"`) goes through the
|
||||
public **OpenAI Platform Realtime API**, which is billed against OpenAI
|
||||
Platform credits rather than Codex/ChatGPT subscription quota. An account
|
||||
with healthy OpenAI OAuth that runs Codex-backed chat models without
|
||||
issue can still hit `insufficient_quota` / "You exceeded your current
|
||||
quota" on the first Realtime turn if the same OpenAI organization has no
|
||||
Platform billing set up.
|
||||
with healthy OpenAI OAuth that runs Codex-backed chat models without issue
|
||||
still needs an OpenAI API-key auth profile or a Platform API key with funded
|
||||
Platform billing for Realtime voice.
|
||||
|
||||
Fix: top up Platform credits at
|
||||
[platform.openai.com/account/billing](https://platform.openai.com/account/billing)
|
||||
for the organization backing your realtime credentials. Realtime accepts
|
||||
either a Platform `OPENAI_API_KEY` (configured via `talk.realtime.providers.openai.apiKey`
|
||||
for Control UI Talk, or `plugins.entries.voice-call.config.realtime.providers.openai.apiKey`
|
||||
for Voice Call) or an `openai` OAuth profile whose underlying
|
||||
organization has Platform billing — both routes mint Realtime client secrets
|
||||
through the Platform API, so either way the org needs funded Platform
|
||||
credits. For chat turns you can still use Codex-backed `openai/*` models against the same
|
||||
OpenClaw install; Realtime is the one route that needs Platform billing.
|
||||
for the organization backing your realtime credentials. Realtime voice accepts
|
||||
the `openai` API-key auth profile created by `openclaw onboard --auth-choice openai-api-key`,
|
||||
a Platform `OPENAI_API_KEY` configured via `talk.realtime.providers.openai.apiKey`
|
||||
for Control UI Talk, `plugins.entries.voice-call.config.realtime.providers.openai.apiKey`
|
||||
for Voice Call, or the `OPENAI_API_KEY` environment variable. OpenAI OAuth
|
||||
profiles can still run Codex-backed `openai/*` chat models in the same
|
||||
OpenClaw install, but they do not configure Realtime voice.
|
||||
</Note>
|
||||
|
||||
## Memory embeddings
|
||||
@@ -646,7 +644,7 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
|
||||
```
|
||||
|
||||
<Note>
|
||||
Set `OPENAI_TTS_BASE_URL` to override the TTS base URL without affecting the chat API endpoint. OpenAI TTS is still configured through an API key; for OAuth-only live talk-back, use the Realtime voice path instead of agent-mode STT -> TTS speech.
|
||||
Set `OPENAI_TTS_BASE_URL` to override the TTS base URL without affecting the chat API endpoint. OpenAI TTS and Realtime voice are both configured through an OpenAI Platform API key; OAuth-only installs can still use Codex-backed chat models, but not OpenAI live talk-back.
|
||||
</Note>
|
||||
|
||||
</Accordion>
|
||||
@@ -717,7 +715,7 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
|
||||
| Silence duration | `...openai.silenceDurationMs` | `500` |
|
||||
| Prefix padding | `...openai.prefixPaddingMs` | `300` |
|
||||
| Reasoning effort | `...openai.reasoningEffort` | (unset) |
|
||||
| Auth | `...openai.apiKey`, `OPENAI_API_KEY`, or `openai` OAuth | Browser Talk and non-Azure backend bridges can use OpenAI OAuth |
|
||||
| Auth | `openai` API-key auth profile, `...openai.apiKey`, or `OPENAI_API_KEY` | OpenAI Platform API key required; OpenAI OAuth does not configure Realtime voice |
|
||||
|
||||
Available built-in Realtime voices for `gpt-realtime-2`: `alloy`, `ash`,
|
||||
`ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, `marin`, `cedar`.
|
||||
@@ -739,10 +737,10 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
|
||||
<Note>
|
||||
Control UI Talk uses OpenAI browser realtime sessions with a Gateway-minted
|
||||
ephemeral client secret and a direct browser WebRTC SDP exchange against the
|
||||
OpenAI Realtime API. When no direct OpenAI API key is configured, the
|
||||
Gateway can mint that client secret with the selected `openai` OAuth
|
||||
profile. Gateway relay and Voice Call backend realtime WebSocket bridges use
|
||||
the same OAuth fallback for native OpenAI endpoints. Maintainer live
|
||||
OpenAI Realtime API. The Gateway mints that client secret with the selected
|
||||
`openai` API-key auth profile or configured OpenAI Platform API key. Gateway
|
||||
relay and Voice Call backend realtime WebSocket bridges use the same
|
||||
API-key-only auth path for native OpenAI endpoints. Maintainer live
|
||||
verification is available with
|
||||
`OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts`;
|
||||
the OpenAI legs verify both the backend WebSocket bridge and the browser
|
||||
|
||||
@@ -209,13 +209,15 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
|
||||
OpenAI web search, and OpenWebUI
|
||||
- `full`: Docker release-path chunks with OpenWebUI
|
||||
- `custom`: exact `docker_lanes` selection for a focused rerun
|
||||
- Run the manual `CI` workflow directly when you only need full normal CI
|
||||
coverage for the release candidate. Manual CI dispatches bypass changed
|
||||
- Run the manual `CI` workflow directly when you only need deterministic normal
|
||||
CI coverage for the release candidate. Manual CI dispatches bypass changed
|
||||
scoping and force the Linux Node shards, bundled-plugin shards, plugin and
|
||||
channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`,
|
||||
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
|
||||
Android, and Control UI i18n lanes.
|
||||
Example: `gh workflow run ci.yml --ref release/YYYY.M.PATCH`
|
||||
built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and
|
||||
Control UI i18n lanes. Standalone manual CI runs Android only when dispatched
|
||||
with `include_android=true`; `Full Release Validation` passes that input for
|
||||
its CI child.
|
||||
Example with Android: `gh workflow run ci.yml --ref release/YYYY.M.PATCH -f include_android=true`
|
||||
- Run `pnpm qa:otel:smoke` when validating release telemetry. It exercises
|
||||
QA-lab through a local OTLP/HTTP receiver and verifies trace, metric, and log
|
||||
export plus bounded trace attributes and content/identifier redaction without
|
||||
@@ -392,9 +394,10 @@ dispatches standalone package Telegram E2E when `release_profile=full` with
|
||||
`npm_telegram_package_spec` is set. `OpenClaw Release
|
||||
Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker
|
||||
release-path coverage when soak is enabled, Package Acceptance with Telegram
|
||||
package QA, QA Lab parity, live Matrix, and live Telegram. A full run is only acceptable when the
|
||||
`Full Release Validation`
|
||||
summary shows `normal_ci` and `release_checks` as successful. In full/all mode,
|
||||
package QA, QA Lab parity, live Matrix, and live Telegram. A full/all run is
|
||||
only acceptable when the `Full Release Validation` summary shows `normal_ci`,
|
||||
`plugin_prerelease`, and `release_checks` as successful, unless a focused rerun
|
||||
intentionally skipped the separate `Plugin Prerelease` child. In full/all mode,
|
||||
the `npm_telegram` child must also be successful; outside full/all it is skipped
|
||||
unless a published `release_package_spec` or `npm_telegram_package_spec` was
|
||||
provided. The final
|
||||
@@ -501,7 +504,9 @@ bypasses changed scoping and forces the normal test graph for the release
|
||||
candidate: Linux Node shards, bundled-plugin shards, plugin and channel contract
|
||||
shards, Node 22 compatibility, `check-*`, `check-additional-*`,
|
||||
built-artifact smoke checks, docs checks, Python skills, Windows, macOS,
|
||||
Android, and Control UI i18n.
|
||||
and Control UI i18n. Android is included when `Full Release Validation` runs the
|
||||
box because the umbrella passes `include_android=true`; standalone manual CI
|
||||
requires `include_android=true` for Android coverage.
|
||||
|
||||
Use this box to answer "did the source tree pass the full normal test suite?"
|
||||
It is not the same as release-path product validation. Evidence to keep:
|
||||
@@ -513,10 +518,13 @@ It is not the same as release-path product validation. Evidence to keep:
|
||||
a run needs performance analysis
|
||||
|
||||
Run manual CI directly only when the release needs deterministic normal CI but
|
||||
not the Docker, QA Lab, live, cross-OS, or package boxes:
|
||||
not the Docker, QA Lab, live, cross-OS, or package boxes. Use the first command
|
||||
for non-Android direct CI. Add `include_android=true` when direct
|
||||
release-candidate CI must cover Android:
|
||||
|
||||
```bash
|
||||
gh workflow run ci.yml --ref main -f target_ref=release/YYYY.M.PATCH
|
||||
gh workflow run ci.yml --ref main -f target_ref=release/YYYY.M.PATCH -f include_android=true
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -274,13 +274,14 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Local (GGUF + node-llama-cpp)">
|
||||
<Accordion title="Local (GGUF + llama.cpp)">
|
||||
| Key | Type | Default | Description |
|
||||
| --------------------- | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `local.modelPath` | `string` | auto-downloaded | Path to GGUF model file |
|
||||
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
|
||||
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
|
||||
|
||||
Install the official llama.cpp provider first: `openclaw plugins install @openclaw/llama-cpp-provider`.
|
||||
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
|
||||
|
||||
Use the standalone CLI to verify the same provider path the Gateway uses:
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
---
|
||||
summary: "Reference design for the public OpenClaw App SDK API, event taxonomy, artifacts, approvals, and package structure"
|
||||
title: "OpenClaw App SDK API design"
|
||||
sidebarTitle: "App SDK API design"
|
||||
read_when:
|
||||
- You are implementing the proposed public OpenClaw app SDK
|
||||
- You need the draft namespace, event, result, artifact, approval, or security contract for the app SDK
|
||||
- You are comparing Gateway protocol resources with the high-level OpenClaw App SDK wrapper
|
||||
---
|
||||
|
||||
This page is the detailed API reference design for the public
|
||||
[OpenClaw App SDK](/concepts/openclaw-sdk). It is intentionally separate from
|
||||
the [Plugin SDK](/plugins/sdk-overview).
|
||||
|
||||
<Note>
|
||||
`@openclaw/sdk` is the external app/client package for talking to the
|
||||
Gateway. `openclaw/plugin-sdk/*` is the in-process plugin authoring contract.
|
||||
Do not import Plugin SDK subpaths from apps that only need to run agents.
|
||||
</Note>
|
||||
|
||||
The public app SDK should be built in two layers:
|
||||
|
||||
1. A low-level generated Gateway client.
|
||||
2. A high-level ergonomic wrapper with `OpenClaw`, `Agent`, `Session`, `Run`,
|
||||
`Task`, `Artifact`, `Approval`, and `Environment` objects.
|
||||
|
||||
## Namespace design
|
||||
|
||||
The low-level namespaces should closely follow Gateway resources:
|
||||
|
||||
```typescript
|
||||
oc.agents.list();
|
||||
oc.agents.get("main");
|
||||
oc.agents.create(...);
|
||||
oc.agents.update(...);
|
||||
|
||||
oc.sessions.list();
|
||||
oc.sessions.create(...);
|
||||
oc.sessions.resolve(...);
|
||||
oc.sessions.send(...);
|
||||
oc.sessions.messages(...);
|
||||
oc.sessions.fork(...);
|
||||
oc.sessions.compact(...);
|
||||
oc.sessions.abort(...);
|
||||
|
||||
oc.runs.create(...);
|
||||
oc.runs.get(runId);
|
||||
oc.runs.events(runId, { after });
|
||||
oc.runs.wait(runId);
|
||||
oc.runs.cancel(runId);
|
||||
|
||||
oc.tasks.list({ status: "running" });
|
||||
oc.tasks.get(taskId);
|
||||
oc.tasks.cancel(taskId, { reason });
|
||||
oc.tasks.events(taskId, { after }); // future API
|
||||
|
||||
oc.models.list();
|
||||
oc.models.status(); // Gateway models.authStatus
|
||||
|
||||
oc.tools.list();
|
||||
oc.tools.invoke("tool-name", { sessionKey, idempotencyKey });
|
||||
|
||||
oc.artifacts.list({ runId });
|
||||
oc.artifacts.get(artifactId, { runId });
|
||||
oc.artifacts.download(artifactId, { runId });
|
||||
|
||||
oc.approvals.list();
|
||||
oc.approvals.respond(approvalId, ...);
|
||||
|
||||
oc.environments.list();
|
||||
oc.environments.create(...); // future API: current SDK throws unsupported
|
||||
oc.environments.status(environmentId);
|
||||
oc.environments.delete(environmentId); // future API: current SDK throws unsupported
|
||||
```
|
||||
|
||||
High-level wrappers should return objects that make common flows pleasant:
|
||||
|
||||
```typescript
|
||||
const run = await agent.run(inputOrParams);
|
||||
await run.cancel();
|
||||
await run.wait();
|
||||
|
||||
for await (const event of run.events()) {
|
||||
// normalized event stream
|
||||
}
|
||||
|
||||
const artifacts = await run.artifacts.list();
|
||||
const session = await run.session();
|
||||
```
|
||||
|
||||
## Event contract
|
||||
|
||||
The public SDK should expose versioned, replayable, normalized events.
|
||||
|
||||
```typescript
|
||||
type OpenClawEvent = {
|
||||
version: 1;
|
||||
id: string;
|
||||
ts: number;
|
||||
type: OpenClawEventType;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
data: unknown;
|
||||
raw?: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
`id` is a replay cursor. Consumers should be able to reconnect with
|
||||
`events({ after: id })` and receive missed events when retention allows.
|
||||
|
||||
Recommended normalized event families:
|
||||
|
||||
| Event | Meaning |
|
||||
| --------------------- | ----------------------------------------------------------- |
|
||||
| `run.created` | Run accepted. |
|
||||
| `run.queued` | Run is waiting for a session lane, runtime, or environment. |
|
||||
| `run.started` | Runtime started execution. |
|
||||
| `run.completed` | Run finished successfully. |
|
||||
| `run.failed` | Run ended with an error. |
|
||||
| `run.cancelled` | Run was cancelled. |
|
||||
| `run.timed_out` | Run exceeded its timeout. |
|
||||
| `assistant.delta` | Assistant text delta. |
|
||||
| `assistant.message` | Complete assistant message or replacement. |
|
||||
| `thinking.delta` | Reasoning or plan delta, when policy allows exposure. |
|
||||
| `tool.call.started` | Tool call began. |
|
||||
| `tool.call.delta` | Tool call streamed progress or partial output. |
|
||||
| `tool.call.completed` | Tool call returned successfully. |
|
||||
| `tool.call.failed` | Tool call failed. |
|
||||
| `approval.requested` | A run or tool needs approval. |
|
||||
| `approval.resolved` | Approval was granted, denied, expired, or cancelled. |
|
||||
| `question.requested` | Runtime asks the user or host app for input. |
|
||||
| `question.answered` | Host app supplied an answer. |
|
||||
| `artifact.created` | New artifact available. |
|
||||
| `artifact.updated` | Existing artifact changed. |
|
||||
| `session.created` | Session created. |
|
||||
| `session.updated` | Session metadata changed. |
|
||||
| `session.compacted` | Session compaction happened. |
|
||||
| `task.updated` | Background task state changed. |
|
||||
| `git.branch` | Runtime observed or changed branch state. |
|
||||
| `git.diff` | Runtime produced or changed a diff. |
|
||||
| `git.pr` | Runtime opened, updated, or linked a pull request. |
|
||||
|
||||
Runtime-native payloads should be available through `raw`, but apps should not
|
||||
have to parse `raw` for normal UI.
|
||||
|
||||
## Result contract
|
||||
|
||||
`Run.wait()` should return a stable result envelope:
|
||||
|
||||
```typescript
|
||||
type RunResult = {
|
||||
runId: string;
|
||||
status: "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
taskId?: string;
|
||||
startedAt?: string | number;
|
||||
endedAt?: string | number;
|
||||
output?: {
|
||||
text?: string;
|
||||
messages?: SDKMessage[];
|
||||
};
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
artifacts?: ArtifactSummary[];
|
||||
error?: SDKError;
|
||||
};
|
||||
```
|
||||
|
||||
The result should be boring and stable. Timestamp values preserve the Gateway
|
||||
shape, so current lifecycle-backed runs usually report epoch millisecond
|
||||
numbers while adapters may still surface ISO strings. Rich UI, tool traces, and
|
||||
runtime-native details belong in events and artifacts.
|
||||
|
||||
`accepted` is a non-terminal wait result: it means the Gateway wait deadline
|
||||
expired before the run produced a lifecycle end/error. It must not be treated as
|
||||
`timed_out`; `timed_out` is reserved for a run that exceeded its own runtime
|
||||
timeout.
|
||||
|
||||
## Approvals and questions
|
||||
|
||||
Approvals must be first-class because coding agents constantly cross safety
|
||||
boundaries.
|
||||
|
||||
```typescript
|
||||
run.onApproval(async (request) => {
|
||||
if (request.kind === "tool" && request.toolName === "exec") {
|
||||
return request.approveOnce({ reason: "CI command allowed by policy" });
|
||||
}
|
||||
|
||||
return request.askUser();
|
||||
});
|
||||
```
|
||||
|
||||
Approval events should carry:
|
||||
|
||||
- approval id
|
||||
- run id and session id
|
||||
- request kind
|
||||
- requested action summary
|
||||
- tool name or environment action
|
||||
- risk level
|
||||
- available decisions
|
||||
- expiration
|
||||
- whether the decision can be reused
|
||||
|
||||
Questions are separate from approvals. A question asks the user or host app for
|
||||
information. An approval asks for permission to perform an action.
|
||||
|
||||
## ToolSpace model
|
||||
|
||||
Apps need to understand the tool surface without importing plugin internals.
|
||||
|
||||
```typescript
|
||||
const tools = await run.toolSpace();
|
||||
|
||||
for (const tool of tools.list()) {
|
||||
console.log(tool.name, tool.source, tool.requiresApproval);
|
||||
}
|
||||
```
|
||||
|
||||
The SDK should expose:
|
||||
|
||||
- normalized tool metadata
|
||||
- source: OpenClaw, MCP, plugin, channel, runtime, or app
|
||||
- schema summary
|
||||
- approval policy
|
||||
- runtime compatibility
|
||||
- whether a tool is hidden, readonly, write capable, or host capable
|
||||
|
||||
Tool invocation through the SDK should be explicit and scoped. Most apps should
|
||||
run agents, not call arbitrary tools directly.
|
||||
|
||||
## Artifact model
|
||||
|
||||
Artifacts should cover more than files.
|
||||
|
||||
```typescript
|
||||
type ArtifactSummary = {
|
||||
id: string;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
type:
|
||||
| "file"
|
||||
| "patch"
|
||||
| "diff"
|
||||
| "log"
|
||||
| "media"
|
||||
| "screenshot"
|
||||
| "trajectory"
|
||||
| "pull_request"
|
||||
| "workspace";
|
||||
title?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Common examples:
|
||||
|
||||
- file edits and generated files
|
||||
- patch bundles
|
||||
- VCS diffs
|
||||
- screenshots and media outputs
|
||||
- logs and trace bundles
|
||||
- pull request links
|
||||
- runtime trajectories
|
||||
- managed environment workspace snapshots
|
||||
|
||||
Artifact access should support redaction, retention, and download URLs without
|
||||
assuming every artifact is a normal local file.
|
||||
|
||||
## Security model
|
||||
|
||||
The app SDK must be explicit about authority.
|
||||
|
||||
Recommended token scopes:
|
||||
|
||||
| Scope | Allows |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| `agent.read` | List and inspect agents. |
|
||||
| `agent.run` | Start runs. |
|
||||
| `session.read` | Read session metadata and messages. |
|
||||
| `session.write` | Create, send to, fork, compact, and abort sessions. |
|
||||
| `task.read` | Read background task state. |
|
||||
| `task.write` | Cancel or modify task notification policy. |
|
||||
| `approval.respond` | Approve or deny requests. |
|
||||
| `tools.invoke` | Invoke exposed tools directly. |
|
||||
| `artifacts.read` | List and download artifacts. |
|
||||
| `environment.write` | Create or destroy managed environments. |
|
||||
| `admin` | Administrative operations. |
|
||||
|
||||
Defaults:
|
||||
|
||||
- no secret forwarding by default
|
||||
- no unrestricted environment variable pass-through
|
||||
- secret references instead of secret values
|
||||
- explicit sandbox and network policy
|
||||
- explicit remote environment retention
|
||||
- approvals for host execution unless policy proves otherwise
|
||||
- raw runtime events redacted before they leave Gateway unless the caller has a
|
||||
stronger diagnostic scope
|
||||
|
||||
## Managed environment provider
|
||||
|
||||
Managed agents should be implemented as environment providers.
|
||||
|
||||
```typescript
|
||||
type EnvironmentProvider = {
|
||||
id: string;
|
||||
capabilities: {
|
||||
checkout?: boolean;
|
||||
sandbox?: boolean;
|
||||
networkPolicy?: boolean;
|
||||
secrets?: boolean;
|
||||
artifacts?: boolean;
|
||||
logs?: boolean;
|
||||
pullRequests?: boolean;
|
||||
longRunning?: boolean;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The first implementation does not need to be a hosted SaaS. It can target
|
||||
existing node hosts, ephemeral workspaces, CI-style runners, or Testbox-style
|
||||
environments. The important contract is:
|
||||
|
||||
1. prepare workspace
|
||||
2. bind safe environment and secrets
|
||||
3. start run
|
||||
4. stream events
|
||||
5. collect artifacts
|
||||
6. clean up or retain by policy
|
||||
|
||||
Once this is stable, a hosted cloud service can implement the same provider
|
||||
contract.
|
||||
|
||||
## Package structure
|
||||
|
||||
Recommended packages:
|
||||
|
||||
| Package | Purpose |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
| `@openclaw/sdk` | Public high-level SDK and generated low-level Gateway client. |
|
||||
| `@openclaw/sdk-react` | Optional React hooks for dashboards and app builders. |
|
||||
| `@openclaw/sdk-testing` | Test helpers and fake Gateway server for app integrations. |
|
||||
|
||||
The repo already has `openclaw/plugin-sdk/*` for plugins. Keep that namespace
|
||||
separate to avoid confusing plugin authors with app developers.
|
||||
|
||||
## Generated client strategy
|
||||
|
||||
The low-level client should be generated from versioned Gateway protocol
|
||||
schemas, then wrapped by handwritten ergonomic classes.
|
||||
|
||||
Layering:
|
||||
|
||||
1. Gateway schema source of truth.
|
||||
2. Generated low-level TypeScript client.
|
||||
3. Runtime validators for external inputs and event payloads.
|
||||
4. High-level `OpenClaw`, `Agent`, `Session`, `Run`, `Task`, and `Artifact`
|
||||
wrappers.
|
||||
5. Cookbook examples and integration tests.
|
||||
|
||||
Benefits:
|
||||
|
||||
- protocol drift is visible
|
||||
- tests can compare generated methods with Gateway exports
|
||||
- App SDK stays independent from Plugin SDK internals
|
||||
- low-level consumers still have full protocol access
|
||||
- high-level consumers get the small product API
|
||||
|
||||
## Related
|
||||
|
||||
- [OpenClaw App SDK](/concepts/openclaw-sdk)
|
||||
- [Gateway RPC reference](/reference/rpc)
|
||||
- [Agent loop](/concepts/agent-loop)
|
||||
- [Agent runtimes](/concepts/agent-runtimes)
|
||||
- [Background tasks](/automation/tasks)
|
||||
- [ACP agents](/tools/acp-agents)
|
||||
- [Plugin SDK overview](/plugins/sdk-overview)
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Generate and edit images via image_generate across OpenAI, Google, fal, MiniMax, ComfyUI, DeepInfra, OpenRouter, LiteLLM, xAI, Vydra"
|
||||
summary: "Generate and edit images via image_generate across OpenAI, Google, fal, Microsoft Foundry, MiniMax, ComfyUI, DeepInfra, OpenRouter, LiteLLM, xAI, Vydra"
|
||||
read_when:
|
||||
- Generating or editing images via the agent
|
||||
- Configuring image-generation providers and models
|
||||
@@ -83,6 +83,7 @@ internal image endpoints remain blocked by default.
|
||||
| fal Krea 2 expressive/style-directed generation | `fal/krea/v2/medium/text-to-image` | `FAL_KEY` |
|
||||
| OpenRouter image generation | `openrouter/google/gemini-3.1-flash-image-preview` | `OPENROUTER_API_KEY` |
|
||||
| LiteLLM image generation | `litellm/gpt-image-2` | `LITELLM_API_KEY` |
|
||||
| Microsoft Foundry MAI image generation | `microsoft-foundry/<deployment-name>` | `AZURE_OPENAI_API_KEY` or Entra ID |
|
||||
| Google Gemini image generation | `google/gemini-3.1-flash-image-preview` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
||||
|
||||
The same `image_generate` tool handles text-to-image and reference-image
|
||||
@@ -97,18 +98,19 @@ backend emits it.
|
||||
|
||||
## Supported providers
|
||||
|
||||
| Provider | Default model | Edit support | Auth |
|
||||
| ---------- | --------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
|
||||
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
|
||||
| DeepInfra | `black-forest-labs/FLUX-1-schnell` | Yes (1 image) | `DEEPINFRA_API_KEY` |
|
||||
| fal | `fal-ai/flux/dev` | Yes (model-specific limits) | `FAL_KEY` |
|
||||
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
||||
| LiteLLM | `gpt-image-2` | Yes (up to 5 input images) | `LITELLM_API_KEY` |
|
||||
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
|
||||
| OpenAI | `gpt-image-2` | Yes (up to 4 images) | `OPENAI_API_KEY` or OpenAI ChatGPT/Codex OAuth |
|
||||
| OpenRouter | `google/gemini-3.1-flash-image-preview` | Yes (up to 5 input images) | `OPENROUTER_API_KEY` |
|
||||
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
|
||||
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
|
||||
| Provider | Default model | Edit support | Auth |
|
||||
| ----------------- | --------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
|
||||
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
|
||||
| DeepInfra | `black-forest-labs/FLUX-1-schnell` | Yes (1 image) | `DEEPINFRA_API_KEY` |
|
||||
| fal | `fal-ai/flux/dev` | Yes (model-specific limits) | `FAL_KEY` |
|
||||
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
|
||||
| LiteLLM | `gpt-image-2` | Yes (up to 5 input images) | `LITELLM_API_KEY` |
|
||||
| Microsoft Foundry | `<deployment-name>` | Yes (MAI-Image-2.5 models only) | `AZURE_OPENAI_API_KEY` or Entra ID (`az login`) |
|
||||
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
|
||||
| OpenAI | `gpt-image-2` | Yes (up to 4 images) | `OPENAI_API_KEY` or OpenAI ChatGPT/Codex OAuth |
|
||||
| OpenRouter | `google/gemini-3.1-flash-image-preview` | Yes (up to 5 input images) | `OPENROUTER_API_KEY` |
|
||||
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
|
||||
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
|
||||
|
||||
Use `action: "list"` to inspect available providers and models at runtime:
|
||||
|
||||
@@ -125,13 +127,13 @@ current session:
|
||||
|
||||
## Provider capabilities
|
||||
|
||||
| Capability | ComfyUI | DeepInfra | fal | Google | MiniMax | OpenAI | Vydra | xAI |
|
||||
| --------------------- | ------------------ | --------- | ---------------------------------------------- | -------------- | --------------------- | -------------- | ----- | -------------- |
|
||||
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 9 | 4 | 1 | 4 |
|
||||
| Edit / reference | 1 image (workflow) | 1 image | Flux: 1; GPT: 10; Krea style refs: 10; NB2: 14 | Up to 5 images | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
|
||||
| Size control | - | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
|
||||
| Aspect ratio | - | - | ✓ | ✓ | ✓ | - | - | ✓ |
|
||||
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | 1K, 2K |
|
||||
| Capability | ComfyUI | DeepInfra | fal | Google | Microsoft Foundry | MiniMax | OpenAI | Vydra | xAI |
|
||||
| --------------------- | ------------------ | --------- | ---------------------------------------------- | -------------- | ----------------- | --------------------- | -------------- | ----- | -------------- |
|
||||
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 1 | 9 | 4 | 1 | 4 |
|
||||
| Edit / reference | 1 image (workflow) | 1 image | Flux: 1; GPT: 10; Krea style refs: 10; NB2: 14 | Up to 5 images | 1 image | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
|
||||
| Size control | - | ✓ | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
|
||||
| Aspect ratio | - | - | ✓ | ✓ | - | ✓ | - | - | ✓ |
|
||||
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | - | 1K, 2K |
|
||||
|
||||
## Tool parameters
|
||||
|
||||
@@ -249,10 +251,10 @@ from each attempt.
|
||||
backends. A per-call `timeoutMs` tool parameter overrides the configured
|
||||
default, and configured defaults override plugin-authored provider
|
||||
defaults. Google and OpenRouter hosted image providers use 180 second
|
||||
defaults; xAI and Azure OpenAI image generation use 600 seconds. Codex
|
||||
dynamic-tool calls use a 120 second `image_generate` bridge default and
|
||||
honor the same timeout budget when configured, bounded by OpenClaw's 600000
|
||||
ms dynamic-tool bridge maximum.
|
||||
defaults; Microsoft Foundry MAI, xAI, and Azure OpenAI image generation use
|
||||
600 seconds. Codex dynamic-tool calls use a 120 second `image_generate`
|
||||
bridge default and honor the same timeout budget when configured, bounded by
|
||||
OpenClaw's 600000 ms dynamic-tool bridge maximum.
|
||||
</Accordion>
|
||||
<Accordion title="Inspect at runtime">
|
||||
Use `action: "list"` to inspect the currently registered providers,
|
||||
@@ -262,9 +264,10 @@ from each attempt.
|
||||
|
||||
### Image editing
|
||||
|
||||
OpenAI, OpenRouter, Google, DeepInfra, fal, MiniMax, ComfyUI, and xAI support editing
|
||||
reference images. Krea 2 models on fal use the same `image` / `images` fields
|
||||
as style references instead of edit inputs. Pass a reference image path or URL:
|
||||
OpenAI, OpenRouter, Google, DeepInfra, fal, Microsoft Foundry, MiniMax,
|
||||
ComfyUI, and xAI support editing reference images. Krea 2 models on fal use the
|
||||
same `image` / `images` fields as style references instead of edit inputs. Pass
|
||||
a reference image path or URL:
|
||||
|
||||
```text
|
||||
"Generate a watercolor version of this photo" + image: "/path/to/photo.jpg"
|
||||
@@ -273,7 +276,7 @@ as style references instead of edit inputs. Pass a reference image path or URL:
|
||||
OpenAI, OpenRouter, Google, and xAI support up to 5 reference images via the
|
||||
`images` parameter. fal supports 1 reference image for Flux image-to-image, up
|
||||
to 10 for GPT Image 2 edits, up to 10 style references for Krea 2, and up to
|
||||
14 for Nano Banana 2 edits. MiniMax and ComfyUI support 1.
|
||||
14 for Nano Banana 2 edits. Microsoft Foundry, MiniMax, and ComfyUI support 1.
|
||||
|
||||
## Provider deep dives
|
||||
|
||||
@@ -334,6 +337,47 @@ to 10 for GPT Image 2 edits, up to 10 style references for Krea 2, and up to
|
||||
instead of `api.openai.com`, see
|
||||
[Azure OpenAI endpoints](/providers/openai#azure-openai-endpoints).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Microsoft Foundry MAI image models">
|
||||
Microsoft Foundry image generation uses deployed MAI image deployment names
|
||||
under the `microsoft-foundry/` provider prefix. There is no provider-level
|
||||
default model because the MAI API expects your deployment name in the
|
||||
`model` field:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
imageGenerationModel: {
|
||||
primary: "microsoft-foundry/<deployment-name>",
|
||||
timeoutMs: 600_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The provider uses Microsoft Foundry's MAI API, not the OpenAI Images API:
|
||||
|
||||
- Generation endpoint: `/mai/v1/images/generations`
|
||||
- Edit endpoint: `/mai/v1/images/edits`
|
||||
- Auth: `AZURE_OPENAI_API_KEY` / provider API key, or Entra ID through `az login`
|
||||
- Output: one PNG image
|
||||
- Size: default `1024x1024`; width and height must each be at least 768 px,
|
||||
and total pixels must be at most 1,048,576
|
||||
- Edits: one PNG or JPEG reference image, supported only by
|
||||
`MAI-Image-2.5-Flash` and `MAI-Image-2.5` deployments
|
||||
|
||||
Prompt-only generation can use a custom deployment name with just the
|
||||
Foundry endpoint configured. Edits with custom deployment names need
|
||||
onboarding/model metadata so OpenClaw can verify that the deployment is
|
||||
backed by `MAI-Image-2.5-Flash` or `MAI-Image-2.5`.
|
||||
|
||||
Current MAI image models are `MAI-Image-2.5-Flash`, `MAI-Image-2.5`,
|
||||
`MAI-Image-2e`, and `MAI-Image-2`. See
|
||||
[Microsoft Foundry plugin](/plugins/reference/microsoft-foundry) for setup
|
||||
and chat-model behavior.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="OpenRouter image models">
|
||||
OpenRouter image generation uses the same `OPENROUTER_API_KEY` and
|
||||
@@ -485,6 +529,7 @@ as ignored for them.
|
||||
- [ComfyUI](/providers/comfy) - local ComfyUI and Comfy Cloud workflow setup
|
||||
- [fal](/providers/fal) - fal image and video provider setup
|
||||
- [Google (Gemini)](/providers/google) - Gemini image provider setup
|
||||
- [Microsoft Foundry plugin](/plugins/reference/microsoft-foundry) - Microsoft Foundry chat and MAI image setup
|
||||
- [MiniMax](/providers/minimax) - MiniMax image provider setup
|
||||
- [OpenAI](/providers/openai) - OpenAI Images provider setup
|
||||
- [Vydra](/providers/vydra) - Vydra image, video, and speech setup
|
||||
|
||||
@@ -52,30 +52,31 @@ telephony, meetings, browser realtime, and native push-to-talk clients.
|
||||
|
||||
## Provider capability matrix
|
||||
|
||||
| Provider | Image | Video | Music | TTS | STT | Realtime voice | Media understanding |
|
||||
| ----------- | :---: | :---: | :---: | :-: | :-: | :------------: | :-----------------: |
|
||||
| Alibaba | | ✓ | | | | | |
|
||||
| BytePlus | | ✓ | | | | | |
|
||||
| ComfyUI | ✓ | ✓ | ✓ | | | | |
|
||||
| DeepInfra | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| Deepgram | | | | | ✓ | ✓ | |
|
||||
| ElevenLabs | | | | ✓ | ✓ | | |
|
||||
| fal | ✓ | ✓ | ✓ | | | | |
|
||||
| Google | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
|
||||
| Gradium | | | | ✓ | | | |
|
||||
| Local CLI | | | | ✓ | | | |
|
||||
| Microsoft | | | | ✓ | | | |
|
||||
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| Mistral | | | | | ✓ | | |
|
||||
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
|
||||
| Qwen | | ✓ | | | | | |
|
||||
| Runway | | ✓ | | | | | |
|
||||
| SenseAudio | | | | | ✓ | | |
|
||||
| Together | | ✓ | | | | | |
|
||||
| Vydra | ✓ | ✓ | | ✓ | | | |
|
||||
| xAI | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| Xiaomi MiMo | ✓ | | | ✓ | | | ✓ |
|
||||
| Provider | Image | Video | Music | TTS | STT | Realtime voice | Media understanding |
|
||||
| ----------------- | :---: | :---: | :---: | :-: | :-: | :------------: | :-----------------: |
|
||||
| Alibaba | | ✓ | | | | | |
|
||||
| BytePlus | | ✓ | | | | | |
|
||||
| ComfyUI | ✓ | ✓ | ✓ | | | | |
|
||||
| DeepInfra | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| Deepgram | | | | | ✓ | ✓ | |
|
||||
| ElevenLabs | | | | ✓ | ✓ | | |
|
||||
| fal | ✓ | ✓ | ✓ | | | | |
|
||||
| Google | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
|
||||
| Gradium | | | | ✓ | | | |
|
||||
| Local CLI | | | | ✓ | | | |
|
||||
| Microsoft | | | | ✓ | | | |
|
||||
| Microsoft Foundry | ✓ | | | | | | |
|
||||
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| Mistral | | | | | ✓ | | |
|
||||
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
|
||||
| Qwen | | ✓ | | | | | |
|
||||
| Runway | | ✓ | | | | | |
|
||||
| SenseAudio | | | | | ✓ | | |
|
||||
| Together | | ✓ | | | | | |
|
||||
| Vydra | ✓ | ✓ | | ✓ | | | |
|
||||
| xAI | ✓ | ✓ | | ✓ | ✓ | | ✓ |
|
||||
| Xiaomi MiMo | ✓ | | | ✓ | | | ✓ |
|
||||
|
||||
<Note>
|
||||
Media understanding uses any vision-capable or audio-capable model registered
|
||||
|
||||
@@ -201,7 +201,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Talk mode (browser realtime)">
|
||||
Talk mode uses a registered realtime voice provider. Configure OpenAI with `talk.realtime.provider: "openai"` plus either `talk.realtime.providers.openai.apiKey`, `OPENAI_API_KEY`, or an `openai` OAuth profile; configure Google with `talk.realtime.provider: "google"` plus `talk.realtime.providers.google.apiKey`. For hosted GPT realtime models, OpenClaw prefers the `openai` OAuth profile before `OPENAI_API_KEY`; an explicit OpenAI realtime `apiKey` remains the advanced override. The browser never receives a standard provider API key. OpenAI receives an ephemeral Realtime client secret for WebRTC. Google Live receives a one-use constrained Live API auth token for a browser WebSocket session, with instructions and tool declarations locked into the token by the Gateway. Providers that only expose a backend realtime bridge run through the Gateway relay transport, so credentials and vendor sockets stay server-side while browser audio moves through authenticated Gateway RPCs. The Realtime session prompt is assembled by the Gateway; `talk.client.create` does not accept caller-provided instruction overrides.
|
||||
Talk mode uses a registered realtime voice provider. Configure OpenAI with `talk.realtime.provider: "openai"` plus an `openai` API-key auth profile, `talk.realtime.providers.openai.apiKey`, or `OPENAI_API_KEY`; OpenAI OAuth profiles do not configure Realtime voice. Configure Google with `talk.realtime.provider: "google"` plus `talk.realtime.providers.google.apiKey`. The browser never receives a standard provider API key. OpenAI receives an ephemeral Realtime client secret for WebRTC. Google Live receives a one-use constrained Live API auth token for a browser WebSocket session, with instructions and tool declarations locked into the token by the Gateway. Providers that only expose a backend realtime bridge run through the Gateway relay transport, so credentials and vendor sockets stay server-side while browser audio moves through authenticated Gateway RPCs. The Realtime session prompt is assembled by the Gateway; `talk.client.create` does not accept caller-provided instruction overrides.
|
||||
|
||||
The Chat composer includes a Talk options button next to the Talk start/stop button. The options apply to the next Talk session and can override provider, transport, model, voice, reasoning effort, VAD threshold, silence duration, and prefix padding. When an option is blank, the Gateway uses configured defaults where available or the provider default. Selecting Gateway relay forces the backend relay path; selecting WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session.
|
||||
|
||||
|
||||
@@ -224,6 +224,56 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the OpenClaw Anthropic provider prefix for Claude ACP startup", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) =>
|
||||
agentName === "claude" ? "npx @agentclientprotocol/claude-agent-acp" : agentName,
|
||||
list: () => ["claude", "openclaw"],
|
||||
},
|
||||
});
|
||||
const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({
|
||||
sessionKey: "agent:claude:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "claude",
|
||||
});
|
||||
|
||||
await runtime.ensureSession({
|
||||
sessionKey: "agent:claude:acp:test",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(readFirstEnsureSessionInput(ensure)).toEqual({
|
||||
sessionKey: "agent:claude:acp:test",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionOptions: { model: "claude-sonnet-4-6" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Claude ACP model ids intact after stripping the OpenClaw provider prefix", () => {
|
||||
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-sonnet-4-6")).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-opus-4-8")).toBe(
|
||||
"claude-opus-4-8",
|
||||
);
|
||||
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-haiku-4-5")).toBe(
|
||||
"claude-haiku-4-5",
|
||||
);
|
||||
expect(testing.normalizeClaudeAcpModelOverride("anthropic/claude-sonnet-4-6-1m")).toBe(
|
||||
"claude-sonnet-4-6-1m",
|
||||
);
|
||||
expect(testing.normalizeClaudeAcpModelOverride("custom-model")).toBe("custom-model");
|
||||
});
|
||||
|
||||
it("leaves Codex ACP startup defaults alone when no model or thinking is provided", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
@@ -898,7 +948,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
expect(setConfigOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still forwards non-timeout config controls for claude-agent-acp", async () => {
|
||||
it("normalizes model config controls for claude-agent-acp", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({
|
||||
acpxRecordId: "agent:claude:acp:test",
|
||||
@@ -918,14 +968,14 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "model",
|
||||
value: "claude-sonnet-4.6",
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(setConfigOption).toHaveBeenCalledOnce();
|
||||
expect(setConfigOption).toHaveBeenCalledWith({
|
||||
handle,
|
||||
key: "model",
|
||||
value: "claude-sonnet-4.6",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ const OPENCLAW_BRIDGE_EXECUTABLE = "openclaw";
|
||||
const OPENCLAW_BRIDGE_SUBCOMMAND = "acp";
|
||||
const CODEX_ACP_AGENT_ID = "codex";
|
||||
const CODEX_ACP_OPENCLAW_PREFIX = "openai/";
|
||||
const CLAUDE_ACP_OPENCLAW_PREFIX = "anthropic/";
|
||||
const CODEX_ACP_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
||||
const CODEX_ACP_THINKING_ALIASES = new Map<string, string | undefined>([
|
||||
["off", undefined],
|
||||
@@ -564,6 +565,17 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
|
||||
: override.model;
|
||||
}
|
||||
|
||||
function normalizeClaudeAcpModelOverride(rawModel: string | undefined): string | undefined {
|
||||
const raw = rawModel?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
if (!raw.toLowerCase().startsWith(CLAUDE_ACP_OPENCLAW_PREFIX)) {
|
||||
return raw;
|
||||
}
|
||||
return raw.slice(CLAUDE_ACP_OPENCLAW_PREFIX.length).trim() || undefined;
|
||||
}
|
||||
|
||||
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
|
||||
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
|
||||
const model = input.model?.trim() || existingOptions?.model;
|
||||
@@ -948,10 +960,14 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
agentRegistry: this.agentRegistry,
|
||||
});
|
||||
const delegate = this.resolveDelegateForCommand(command);
|
||||
const claudeModelOverride = isClaudeAcpCommand(command)
|
||||
? normalizeClaudeAcpModelOverride(input.model)
|
||||
: undefined;
|
||||
const codexModelOverride =
|
||||
normalizeAgentName(input.agent) === CODEX_ACP_AGENT_ID && isCodexAcpCommand(command)
|
||||
? normalizeCodexAcpModelOverride(input.model, input.thinking)
|
||||
: undefined;
|
||||
const ensureInput = claudeModelOverride ? { ...input, model: claudeModelOverride } : input;
|
||||
const stableLaunchCommand =
|
||||
codexModelOverride && command
|
||||
? appendCodexAcpConfigOverrides(command, codexModelOverride)
|
||||
@@ -966,20 +982,20 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
|
||||
if (!codexModelOverride) {
|
||||
return await this.runWithLaunchLease({
|
||||
sessionKey: input.sessionKey,
|
||||
sessionKey: ensureInput.sessionKey,
|
||||
command: stableLaunchCommand,
|
||||
enabled: shouldStartWithLease,
|
||||
run: () =>
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedInput = {
|
||||
...input,
|
||||
...ensureInput,
|
||||
...(codexAcpSessionModelId(codexModelOverride)
|
||||
? { model: codexAcpSessionModelId(codexModelOverride) }
|
||||
: {}),
|
||||
@@ -1208,6 +1224,13 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isClaudeAcpCommand(command) && key === "model") {
|
||||
await delegate.setConfigOption({
|
||||
...input,
|
||||
value: normalizeClaudeAcpModelOverride(input.value) ?? input.value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await delegate.setConfigOption(input);
|
||||
}
|
||||
|
||||
@@ -1260,6 +1283,7 @@ export const testing = {
|
||||
codexAcpSessionModelId,
|
||||
isClaudeAcpCommand,
|
||||
isCodexAcpCommand,
|
||||
normalizeClaudeAcpModelOverride,
|
||||
normalizeCodexAcpModelOverride,
|
||||
};
|
||||
|
||||
|
||||
56
extensions/codex/npm-shrinkwrap.json
generated
56
extensions/codex/npm-shrinkwrap.json
generated
@@ -8,16 +8,16 @@
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.2",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.135.0",
|
||||
"@openai/codex": "0.137.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.135.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0.tgz",
|
||||
"integrity": "sha512-ID75QEYmAT1WsUQmpxPlNsL5W1a+2eeD7fP6ywdwGseiXUG8D5i16L+dzbr8MT+2oTkaVqzOdvAqVOCeV/H/Bw==",
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0.tgz",
|
||||
"integrity": "sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
@@ -26,19 +26,19 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.135.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.135.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.135.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.135.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.135.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.135.0-win32-x64"
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.137.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.137.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.137.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.137.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.137.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.137.0-win32-x64"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-wpNzssusKfrldVlq39+HyQh1wCyc9SQNpHdAFGKtPenrgRte4Ct8/oVsDtKWuFZsqLBFwbL4MrzrevnB63+9HA==",
|
||||
"version": "0.137.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -53,9 +53,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-ZrjAqce23lbv9KfkYOhElf1lTI+SysXmyGM0FV5u4+PBCKPkkEs4eaS3H8Uig0i4bUSu1QylrOOCskzYhZ6VyQ==",
|
||||
"version": "0.137.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -70,9 +70,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-dM+cv5ZL+BgIQzEIvMg9AxZ98n5lkKLgtp5zJLXWSrbCllbnUSqxYMUiWI5c1a1uBDUtkbY9fcGKXFLf+d+gyg==",
|
||||
"version": "0.137.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -87,9 +87,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-linux-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-linux-x64.tgz",
|
||||
"integrity": "sha512-5EosY67yU28UJSnl/obdN2F1CDaimYbzm9SLR8dwwzkeBBnY6dHgAKJ2GTu9Nc8CmgmtVFBGzgPqehsIcueVvA==",
|
||||
"version": "0.137.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-linux-x64.tgz",
|
||||
"integrity": "sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -104,9 +104,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-SAeR+CUv7KWwE6eTc2UFaFjo6FpHywYfDFKrK6FqLms1rq1NPju2SoX7rhM6UEew/lUx2mdZv/LDs11s/N/Qgg==",
|
||||
"version": "0.137.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -121,9 +121,9 @@
|
||||
},
|
||||
"node_modules/@openai/codex-win32-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.135.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.135.0-win32-x64.tgz",
|
||||
"integrity": "sha512-uYwUBMbOfmVlCESJZmZsOG+cYwNFYvkMbQ+FB6C1u9RYz0m3mZeYNN0j+l1hRSyUgPMFJHzNpgNx1Usal5QZFQ==",
|
||||
"version": "0.137.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.137.0-win32-x64.tgz",
|
||||
"integrity": "sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.135.0",
|
||||
"@openai/codex": "0.137.0",
|
||||
"typebox": "1.1.39",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCodexNativeHookRelayConfig,
|
||||
buildCodexNativeHookRelayDisabledConfig,
|
||||
resolveCodexNativeHookRelayCommandTimeoutMs,
|
||||
resolveCodexNativeHookRelayUnregisterGraceMs,
|
||||
} from "./native-hook-relay.js";
|
||||
|
||||
@@ -23,7 +24,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --timeout 6000",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -37,7 +38,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use --timeout 6000",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -51,7 +52,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 6000",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -65,7 +66,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize --timeout 6000",
|
||||
timeout: 7,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -128,7 +129,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 4000",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -163,7 +164,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --timeout 4000",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -200,7 +201,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use --pre-tool-use-unavailable noop --timeout 4000",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -238,7 +239,7 @@ describe("Codex native hook relay config", () => {
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
|
||||
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request --timeout 4000",
|
||||
timeout: 5,
|
||||
async: false,
|
||||
statusMessage: "OpenClaw native hook relay",
|
||||
@@ -266,6 +267,12 @@ describe("Codex native hook relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves relay timeout margin before Codex can kill the hook subprocess", () => {
|
||||
expect(resolveCodexNativeHookRelayCommandTimeoutMs(undefined)).toBe(4000);
|
||||
expect(resolveCodexNativeHookRelayCommandTimeoutMs(1)).toBe(750);
|
||||
expect(resolveCodexNativeHookRelayCommandTimeoutMs(7)).toBe(6000);
|
||||
});
|
||||
|
||||
it("omits matchers so Codex MCP tool names reach the relay with a stable trust hash", () => {
|
||||
const config = buildCodexNativeHookRelayConfig({
|
||||
relay: createRelay(),
|
||||
@@ -311,12 +318,12 @@ function createRelay(options?: {
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"],
|
||||
expiresAtMs: Date.now() + 1000,
|
||||
shouldRelayEvent: (event) => !inactiveEvents.has(event),
|
||||
commandForEvent: (event) =>
|
||||
commandForEvent: (event, commandOptions) =>
|
||||
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}${
|
||||
event === "pre_tool_use" && inactiveEvents.has(event)
|
||||
? " --pre-tool-use-unavailable noop"
|
||||
: ""
|
||||
}`,
|
||||
}${commandOptions?.timeoutMs ? ` --timeout ${commandOptions.timeoutMs}` : ""}`,
|
||||
renew: () => undefined,
|
||||
unregister: () => undefined,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,8 @@ const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
|
||||
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
|
||||
/** Extra relay lifetime after the expected turn budget, preventing late hook drops. */
|
||||
export const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
|
||||
const CODEX_NATIVE_HOOK_RELAY_COMMAND_MIN_PARENT_MARGIN_MS = 250;
|
||||
const CODEX_NATIVE_HOOK_RELAY_COMMAND_MAX_PARENT_MARGIN_MS = 1_000;
|
||||
const CODEX_NATIVE_HOOK_RELAY_UNREGISTER_GRACE_MS = 10_000;
|
||||
const CODEX_NATIVE_HOOK_RELAY_UNREGISTER_EXTRA_GRACE_MS = 5_000;
|
||||
|
||||
@@ -263,8 +265,10 @@ export function buildCodexNativeHookRelayConfig(params: {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const command = params.relay.commandForEvent(event);
|
||||
const timeout = normalizeHookTimeoutSec(params.hookTimeoutSec);
|
||||
const command = params.relay.commandForEvent(event, {
|
||||
timeoutMs: resolveCodexNativeHookRelayCommandTimeoutMs(timeout),
|
||||
});
|
||||
config[`hooks.${codexEvent}`] = [
|
||||
{
|
||||
hooks: [
|
||||
@@ -311,6 +315,18 @@ function normalizeHookTimeoutSec(value: number | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.ceil(value) : 5;
|
||||
}
|
||||
|
||||
export function resolveCodexNativeHookRelayCommandTimeoutMs(
|
||||
hookTimeoutSec: number | undefined,
|
||||
): number {
|
||||
const parentTimeoutMs =
|
||||
finiteSecondsToTimerSafeMilliseconds(normalizeHookTimeoutSec(hookTimeoutSec)) ?? 5_000;
|
||||
const parentMarginMs = Math.min(
|
||||
CODEX_NATIVE_HOOK_RELAY_COMMAND_MAX_PARENT_MARGIN_MS,
|
||||
Math.max(CODEX_NATIVE_HOOK_RELAY_COMMAND_MIN_PARENT_MARGIN_MS, Math.floor(parentTimeoutMs / 5)),
|
||||
);
|
||||
return Math.max(1, parentTimeoutMs - parentMarginMs);
|
||||
}
|
||||
|
||||
function codexCommandHookTrustedHash(params: {
|
||||
event: NativeHookRelayEvent;
|
||||
command: string;
|
||||
|
||||
@@ -380,7 +380,7 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
const runtime = createRuntime();
|
||||
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
|
||||
codexHome,
|
||||
transcriptPollDelaysMs: [10],
|
||||
transcriptPollDelaysMs: [10, 1],
|
||||
});
|
||||
monitor.registerParent({
|
||||
parentThreadId: "parent-thread",
|
||||
@@ -400,15 +400,20 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
status: "succeeded",
|
||||
statusLabel: "completed_without_final_message",
|
||||
result: "Codex native subagent completed without a final assistant message.",
|
||||
}),
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionId: "child-thread",
|
||||
status: "succeeded",
|
||||
statusLabel: "completed_without_final_message",
|
||||
result: "Codex native subagent completed without a final assistant message.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
client.close();
|
||||
|
||||
@@ -606,23 +606,31 @@ export class CodexNativeSubagentMonitor {
|
||||
const delayMs = noFinalCompletionFallbackDelayMs(this.transcriptPollDelaysMs);
|
||||
childState.noFinalCompletionFallbackTimer = setTimeout(() => {
|
||||
childState.noFinalCompletionFallbackTimer = undefined;
|
||||
void this.reconcileChildTranscript(childState.childThreadId)
|
||||
.catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
|
||||
childThreadId: childState.childThreadId,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.then((reconciled) => {
|
||||
if (!reconciled && !childState.transcriptTerminal) {
|
||||
void this.processCompletion(state, completion, eventAt);
|
||||
}
|
||||
});
|
||||
void this.deliverNoFinalCompletionFallback(state, childState, completion, eventAt);
|
||||
}, delayMs);
|
||||
unrefTimer(childState.noFinalCompletionFallbackTimer);
|
||||
}
|
||||
|
||||
private async deliverNoFinalCompletionFallback(
|
||||
state: ParentState,
|
||||
childState: ChildState,
|
||||
completion: CodexNativeSubagentCompletion,
|
||||
eventAt: number,
|
||||
): Promise<void> {
|
||||
const reconciled = await this.reconcileChildTranscript(childState.childThreadId).catch(
|
||||
(error: unknown): false => {
|
||||
embeddedAgentLog.warn("Failed to reconcile Codex native subagent transcript", {
|
||||
childThreadId: childState.childThreadId,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
},
|
||||
);
|
||||
if (!reconciled && !childState.transcriptTerminal) {
|
||||
await this.processCompletion(state, completion, eventAt);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.taskRowReconcileTimer) {
|
||||
clearInterval(this.taskRowReconcileTimer);
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "DynamicToolCallParams",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"arguments",
|
||||
"callId",
|
||||
"threadId",
|
||||
"tool",
|
||||
"turnId"
|
||||
],
|
||||
"properties": {
|
||||
"arguments": true,
|
||||
"callId": {
|
||||
@@ -29,5 +20,14 @@
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"arguments",
|
||||
"callId",
|
||||
"threadId",
|
||||
"tool",
|
||||
"turnId"
|
||||
],
|
||||
"title": "DynamicToolCallParams",
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -1,33 +1,127 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ErrorNotification",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"threadId",
|
||||
"turnId",
|
||||
"willRetry"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/definitions/TurnError"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"willRetry": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"CodexErrorInfo": {
|
||||
"description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"httpConnectionFailed": {
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"format": "uint16",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"httpConnectionFailed"
|
||||
],
|
||||
"title": "HttpConnectionFailedCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Failed to connect to the response SSE stream.",
|
||||
"properties": {
|
||||
"responseStreamConnectionFailed": {
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"format": "uint16",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"responseStreamConnectionFailed"
|
||||
],
|
||||
"title": "ResponseStreamConnectionFailedCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "The response SSE stream disconnected in the middle of a turn before completion.",
|
||||
"properties": {
|
||||
"responseStreamDisconnected": {
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"format": "uint16",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"responseStreamDisconnected"
|
||||
],
|
||||
"title": "ResponseStreamDisconnectedCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Reached the retry limit for responses.",
|
||||
"properties": {
|
||||
"responseTooManyFailedAttempts": {
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"format": "uint16",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"responseTooManyFailedAttempts"
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"contextWindowExceeded",
|
||||
"usageLimitExceeded",
|
||||
@@ -39,139 +133,19 @@
|
||||
"threadRollbackFailed",
|
||||
"sandboxError",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"httpConnectionFailed"
|
||||
],
|
||||
"properties": {
|
||||
"httpConnectionFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint16",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "HttpConnectionFailedCodexErrorInfo"
|
||||
},
|
||||
{
|
||||
"description": "Failed to connect to the response SSE stream.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"responseStreamConnectionFailed"
|
||||
],
|
||||
"properties": {
|
||||
"responseStreamConnectionFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint16",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "ResponseStreamConnectionFailedCodexErrorInfo"
|
||||
},
|
||||
{
|
||||
"description": "The response SSE stream disconnected in the middle of a turn before completion.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"responseStreamDisconnected"
|
||||
],
|
||||
"properties": {
|
||||
"responseStreamDisconnected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint16",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "ResponseStreamDisconnectedCodexErrorInfo"
|
||||
},
|
||||
{
|
||||
"description": "Reached the retry limit for responses.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"responseTooManyFailedAttempts"
|
||||
],
|
||||
"properties": {
|
||||
"responseTooManyFailedAttempts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"httpStatusCode": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint16",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo"
|
||||
},
|
||||
{
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo"
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
]
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TurnError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"additionalDetails": {
|
||||
"default": null,
|
||||
@@ -193,7 +167,33 @@
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/definitions/TurnError"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
},
|
||||
"willRetry": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"threadId",
|
||||
"turnId",
|
||||
"willRetry"
|
||||
],
|
||||
"title": "ErrorNotification",
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,84 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requiresOpenaiAuth"
|
||||
],
|
||||
"definitions": {
|
||||
"Account": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"apiKey"
|
||||
],
|
||||
"title": "ApiKeyAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "ApiKeyAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/PlanType"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"chatgpt"
|
||||
],
|
||||
"title": "ChatgptAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"planType",
|
||||
"type"
|
||||
],
|
||||
"title": "ChatgptAccount",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"amazonBedrock"
|
||||
],
|
||||
"title": "AmazonBedrockAccountType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "AmazonBedrockAccount",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PlanType": {
|
||||
"enum": [
|
||||
"free",
|
||||
"go",
|
||||
"plus",
|
||||
"pro",
|
||||
"prolite",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"account": {
|
||||
"anyOf": [
|
||||
@@ -20,83 +94,9 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"Account": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"apiKey"
|
||||
],
|
||||
"title": "ApiKeyAccountType"
|
||||
}
|
||||
},
|
||||
"title": "ApiKeyAccount"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"planType",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"planType": {
|
||||
"$ref": "#/definitions/PlanType"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"chatgpt"
|
||||
],
|
||||
"title": "ChatgptAccountType"
|
||||
}
|
||||
},
|
||||
"title": "ChatgptAccount"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"amazonBedrock"
|
||||
],
|
||||
"title": "AmazonBedrockAccountType"
|
||||
}
|
||||
},
|
||||
"title": "AmazonBedrockAccount"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PlanType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"go",
|
||||
"plus",
|
||||
"pro",
|
||||
"prolite",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
]
|
||||
}
|
||||
}
|
||||
"required": [
|
||||
"requiresOpenaiAuth"
|
||||
],
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -1,65 +1,34 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ModelListResponse",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Model"
|
||||
}
|
||||
},
|
||||
"nextCursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"InputModality": {
|
||||
"description": "Canonical user-input modality tags advertised by a model.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Plain text turns and tool payloads.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text"
|
||||
]
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Image attachments included in user turns.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image"
|
||||
]
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Model": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"defaultReasoningEffort",
|
||||
"description",
|
||||
"displayName",
|
||||
"hidden",
|
||||
"id",
|
||||
"isDefault",
|
||||
"model",
|
||||
"supportedReasoningEfforts"
|
||||
],
|
||||
"properties": {
|
||||
"additionalSpeedTiers": {
|
||||
"description": "Deprecated: use `serviceTiers` instead.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"description": "Deprecated: use `serviceTiers` instead.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"availabilityNux": {
|
||||
"anyOf": [
|
||||
@@ -74,6 +43,14 @@
|
||||
"defaultReasoningEffort": {
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
},
|
||||
"defaultServiceTier": {
|
||||
"default": null,
|
||||
"description": "Catalog default service tier id for this model, when one is configured.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -91,10 +68,10 @@
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/InputModality"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
@@ -104,16 +81,16 @@
|
||||
},
|
||||
"serviceTiers": {
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ModelServiceTier"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"supportedReasoningEfforts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ReasoningEffortOption"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"supportsPersonality": {
|
||||
"default": false,
|
||||
@@ -135,26 +112,31 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"defaultReasoningEffort",
|
||||
"description",
|
||||
"displayName",
|
||||
"hidden",
|
||||
"id",
|
||||
"isDefault",
|
||||
"model",
|
||||
"supportedReasoningEfforts"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelAvailabilityNux": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelServiceTier": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
@@ -165,13 +147,15 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelUpgradeInfo": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"properties": {
|
||||
"migrationMarkdown": {
|
||||
"type": [
|
||||
@@ -194,11 +178,14 @@
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
@@ -206,14 +193,10 @@
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffortOption": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"reasoningEffort"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
@@ -221,7 +204,32 @@
|
||||
"reasoningEffort": {
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"reasoningEffort"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/Model"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"nextCursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"title": "ModelListResponse",
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@ export type CodexThreadStartParams = JsonObject & {
|
||||
developerInstructions?: string;
|
||||
experimentalRawEvents?: boolean;
|
||||
environments?: CodexTurnEnvironmentParams[] | null;
|
||||
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
|
||||
persistExtendedHistory?: boolean;
|
||||
};
|
||||
|
||||
@@ -104,6 +105,7 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
config?: JsonObject;
|
||||
developerInstructions?: string;
|
||||
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
|
||||
persistExtendedHistory?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,4 +9,4 @@ export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
|
||||
// Keep this in sync with the Codex CLI live-test package pin.
|
||||
/** Managed Codex app-server package version installed by OpenClaw. */
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.135.0";
|
||||
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.137.0";
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
createEmptyPluginRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
saveSessionStore,
|
||||
type SessionEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelType, type AutocompleteInteraction } from "../internal/discord.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
@@ -130,6 +134,25 @@ let findCommandByNativeName: typeof import("openclaw/plugin-sdk/command-auth-nat
|
||||
let resolveCommandArgChoices: typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgChoices;
|
||||
let resolveDiscordNativeChoiceContext: typeof import("./native-command-model-picker-ui.js").resolveDiscordNativeChoiceContext;
|
||||
|
||||
async function saveSessionOverride(params: {
|
||||
providerOverride: string;
|
||||
modelOverride: string;
|
||||
}): Promise<void> {
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
await saveSessionStore(
|
||||
STORE_PATH,
|
||||
{
|
||||
[SESSION_KEY]: {
|
||||
sessionId: "main",
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: params.providerOverride,
|
||||
modelOverride: params.modelOverride,
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>,
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
}
|
||||
|
||||
function installProviderThinkingRegistryForTest(): void {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.providers.push({
|
||||
@@ -199,7 +222,7 @@ describe("discord native /think autocomplete", () => {
|
||||
await loadDiscordThinkAutocompleteModulesForTest());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
clearSessionStoreCacheForTest();
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
@@ -218,18 +241,10 @@ describe("discord native /think autocomplete", () => {
|
||||
: undefined,
|
||||
);
|
||||
installProviderThinkingRegistryForTest();
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
JSON.stringify({
|
||||
[SESSION_KEY]: {
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.4",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await saveSessionOverride({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.4",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -318,17 +333,10 @@ describe("discord native /think autocomplete", () => {
|
||||
? { levels: [{ id: "off" }, { id: "max" }] }
|
||||
: undefined,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
JSON.stringify({
|
||||
[SESSION_KEY]: {
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-7",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await saveSessionOverride({
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-7",
|
||||
});
|
||||
const cfg = createConfig();
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
|
||||
const interaction = {
|
||||
|
||||
@@ -89,19 +89,109 @@ export function createFeishuApiError(
|
||||
return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error });
|
||||
}
|
||||
|
||||
// Feishu message-API error codes that signal a transient rate limit; safe to retry with backoff.
|
||||
// 230020: per-chat rate limit (ext=chat rate limit) — confirmed by real concurrent load test.
|
||||
// 11232: tenant-level "create message service trigger rate limit" (100/min, 5/sec per app/bot).
|
||||
// Distinct from FEISHU_BACKOFF_CODES in typing.ts, which covers the reaction API (99991400+).
|
||||
const FEISHU_SEND_RATE_LIMIT_CODES = new Set([230020, 11232]);
|
||||
const FEISHU_SEND_MAX_RETRIES = 2;
|
||||
const FEISHU_SEND_RETRY_BASE_MS = 500;
|
||||
|
||||
/**
|
||||
* Returns a numeric rate-limit signal when an AxiosError indicates a retryable
|
||||
* Feishu message-API rate limit. Sources, in priority order:
|
||||
* 1. Gateway-level HTTP 429 (app-wide quota; `x-ogw-ratelimit-reset` header)
|
||||
* 2. Business-level `code` in `error.response.data.code` matching
|
||||
* FEISHU_SEND_RATE_LIMIT_CODES (e.g. 230020 per-chat, 11232 tenant-level).
|
||||
* Returns `undefined` for all other errors so they propagate without retry.
|
||||
*/
|
||||
export function getFeishuSendRateLimitCode(error: unknown): number | undefined {
|
||||
if (!isRecord(error)) {
|
||||
return undefined;
|
||||
}
|
||||
const response = isRecord(error.response) ? error.response : undefined;
|
||||
// HTTP 429: Feishu Open API gateway-level rate limit, always retry.
|
||||
if (typeof response?.status === "number" && response.status === 429) {
|
||||
return 429;
|
||||
}
|
||||
const data = isRecord(response?.data) ? response.data : undefined;
|
||||
const code = data?.code;
|
||||
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a retryable rate-limit code when a fulfilled (non-throwing) Feishu
|
||||
* SDK response embeds it in the response body. The Feishu node SDK can resolve
|
||||
* with `{ code: 11232, msg: "..." }` instead of throwing — see typing.ts
|
||||
* (getBackoffCodeFromResponse) and issue #28157 for the same behavior on
|
||||
* messageReaction.create. Without this classification, requestFeishuApi would
|
||||
* `return` the rate-limited body and downstream `assertFeishuMessageApiSuccess`
|
||||
* would fail once with no retry.
|
||||
*/
|
||||
export function getFeishuSendRateLimitCodeFromResponse(response: unknown): number | undefined {
|
||||
if (!isRecord(response)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (response as { code?: unknown }).code;
|
||||
return typeof code === "number" && FEISHU_SEND_RATE_LIMIT_CODES.has(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export async function requestFeishuApi<T>(
|
||||
request: () => Promise<T>,
|
||||
errorPrefix: string,
|
||||
options: {
|
||||
includeConfigParams?: boolean;
|
||||
includeNestedErrorLogId?: boolean;
|
||||
/** Base delay per retry attempt in ms; multiplied by attempt index. @internal */
|
||||
retryDelayMs?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
throw createFeishuApiError(error, errorPrefix, options);
|
||||
const retryDelayMs = options.retryDelayMs ?? FEISHU_SEND_RETRY_BASE_MS;
|
||||
let lastFulfilledRateLimit: { response: unknown; code: number } | undefined;
|
||||
for (let attempt = 0; attempt <= FEISHU_SEND_MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
// Linear backoff: delay grows with each attempt to give the rate-limit window time to reset.
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * retryDelayMs);
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await request();
|
||||
// Feishu SDK may fulfill with a rate-limit body (e.g. { code: 11232, ... })
|
||||
// instead of throwing. Classify before returning so retry covers both shapes.
|
||||
const fulfilledRateLimit = getFeishuSendRateLimitCodeFromResponse(result);
|
||||
if (fulfilledRateLimit !== undefined) {
|
||||
// Capture for the synthetic-error path below; on a non-final attempt
|
||||
// continue retrying, on the final attempt fall through so the loop
|
||||
// exits and the wrapped exhaustion error is thrown.
|
||||
lastFulfilledRateLimit = { response: result, code: fulfilledRateLimit };
|
||||
if (attempt < FEISHU_SEND_MAX_RETRIES) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const isRetryable =
|
||||
attempt < FEISHU_SEND_MAX_RETRIES && getFeishuSendRateLimitCode(error) !== undefined;
|
||||
if (!isRetryable) {
|
||||
throw createFeishuApiError(error, errorPrefix, options);
|
||||
}
|
||||
// Rate-limit on a non-final attempt — loop continues to next retry.
|
||||
}
|
||||
}
|
||||
// Exhausted retries while the SDK kept fulfilling rate-limit bodies. Surface
|
||||
// the last response as an error so callers see the same wrapped shape they
|
||||
// would have seen if the SDK had thrown.
|
||||
if (lastFulfilledRateLimit) {
|
||||
const synthetic = Object.assign(
|
||||
new Error(`Request fulfilled with rate-limit code ${lastFulfilledRateLimit.code}`),
|
||||
{ response: { status: 200, data: lastFulfilledRateLimit.response } },
|
||||
);
|
||||
throw createFeishuApiError(synthetic, errorPrefix, options);
|
||||
}
|
||||
// Unreachable: every iteration either returns or throws. Required for TypeScript exhaustiveness.
|
||||
throw createFeishuApiError(new Error("unreachable"), errorPrefix, options);
|
||||
}
|
||||
|
||||
type ParsedCommentDocumentRef = {
|
||||
|
||||
288
extensions/feishu/src/send.concurrent.test.ts
Normal file
288
extensions/feishu/src/send.concurrent.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Concurrent Feishu message send stress tests.
|
||||
*
|
||||
* Verifies that sendMessageFeishu behaves correctly under concurrent load,
|
||||
* including the rate-limit error code (230020) the Feishu API returns when
|
||||
* the per-chat request frequency is too high. Related: issue #70879.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
const {
|
||||
mockClientCreate,
|
||||
mockCreateFeishuClient,
|
||||
mockResolveFeishuAccount,
|
||||
mockConvertMarkdownTables,
|
||||
mockResolveMarkdownTableMode,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClientCreate: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
mockConvertMarkdownTables: vi.fn((text: string) => text),
|
||||
mockResolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient }));
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: mockResolveFeishuAccount,
|
||||
resolveFeishuRuntimeAccount: mockResolveFeishuAccount,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/markdown-table-runtime", () => ({
|
||||
resolveMarkdownTableMode: mockResolveMarkdownTableMode,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/text-chunking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-chunking")>();
|
||||
return { ...actual, convertMarkdownTables: mockConvertMarkdownTables };
|
||||
});
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
|
||||
|
||||
const MOCK_CFG = {} as ClawdbotConfig;
|
||||
|
||||
/** Build a successful send response. */
|
||||
function okResponse(messageId: string) {
|
||||
return { code: 0, data: { message_id: messageId } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AxiosError-shaped object for a Feishu rate-limit HTTP 400 response.
|
||||
* Mirrors what @larksuiteoapi/node-sdk throws when the server returns code 230020.
|
||||
*/
|
||||
function axiosRateLimitError(code = 230020) {
|
||||
return Object.assign(new Error("Request failed with status code 400"), {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
code,
|
||||
msg: "This operation triggers the frequency limit, ext=chat rate limit",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ sendMessageFeishu } = await import("./send.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveFeishuAccount.mockReturnValue({ accountId: "default", configured: true });
|
||||
mockResolveMarkdownTableMode.mockReturnValue("preserve");
|
||||
mockConvertMarkdownTables.mockImplementation((text: string) => text);
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
im: { message: { create: mockClientCreate } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("Concurrent Feishu sends — happy path", () => {
|
||||
it("all concurrent sends succeed when API responds without errors", async () => {
|
||||
const CONCURRENCY = 10;
|
||||
let n = 0;
|
||||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_happy_${n++}`)));
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_chat_${i}`, text: `Message ${i}` }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(CONCURRENCY);
|
||||
for (const result of results) {
|
||||
expect(result.messageId).toBeTruthy();
|
||||
expect(result.receipt).toBeDefined();
|
||||
}
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
|
||||
});
|
||||
|
||||
it("sends 20 messages concurrently and all resolve independently", async () => {
|
||||
const CONCURRENCY = 20;
|
||||
let n = 0;
|
||||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_concurrent_${n++}`)));
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_stress", text: `stress-${i}` }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(CONCURRENCY);
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
|
||||
|
||||
// All message IDs should be unique
|
||||
const messageIds = results.map((r) => r.messageId);
|
||||
expect(new Set(messageIds).size).toBe(CONCURRENCY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Concurrent Feishu sends — rate-limit behavior (code 230020)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("throws on rate-limit code 230020 after exhausting retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||||
|
||||
// Promise.allSettled attaches a rejection handler synchronously, so when
|
||||
// vi.runAllTimersAsync advances timers and fires the rejection, it is
|
||||
// already handled. Using expect().rejects.toThrow() would defer the
|
||||
// attachment via Promise.resolve().then(), causing an unhandled-rejection
|
||||
// warning before the handler is registered.
|
||||
const settled = Promise.allSettled([
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_rl", text: "rate limited" }),
|
||||
]);
|
||||
await vi.runAllTimersAsync();
|
||||
const [result] = await settled;
|
||||
|
||||
expect(result.status).toBe("rejected");
|
||||
// 1 initial attempt + 2 retries = 3 total calls
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("some concurrent sends fail with rate-limit while others succeed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const HALF = 4;
|
||||
let n = 0;
|
||||
|
||||
// Distinguish sends by receive_id: targets containing "fail" always rate-limit.
|
||||
mockClientCreate.mockImplementation((params: { data?: { receive_id?: string } }) => {
|
||||
const target = params?.data?.receive_id ?? "";
|
||||
if (target.includes("fail")) {
|
||||
return Promise.reject(axiosRateLimitError());
|
||||
}
|
||||
return Promise.resolve(okResponse(`om_ok_${n++}`));
|
||||
});
|
||||
|
||||
const settled = Promise.allSettled([
|
||||
...Array.from({ length: HALF }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_fail_${i}`, text: `fail-${i}` }),
|
||||
),
|
||||
...Array.from({ length: HALF }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_ok_${i}`, text: `ok-${i}` }),
|
||||
),
|
||||
]);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const results = await settled;
|
||||
|
||||
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(HALF);
|
||||
expect(results.filter((r) => r.status === "rejected")).toHaveLength(HALF);
|
||||
// Rate-limited sends: HALF × 3 calls (1 + 2 retries); successful sends: HALF × 1 call
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(HALF * 3 + HALF);
|
||||
});
|
||||
|
||||
it("all concurrent sends fail gracefully when API consistently rate-limits", async () => {
|
||||
vi.useFakeTimers();
|
||||
const CONCURRENCY = 5;
|
||||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||||
|
||||
const settled = Promise.allSettled(
|
||||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_all_fail", text: `msg-${i}` }),
|
||||
),
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const results = await settled;
|
||||
|
||||
expect(results.every((r) => r.status === "rejected")).toBe(true);
|
||||
// Each send retries twice: CONCURRENCY × 3 total calls
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY * 3);
|
||||
});
|
||||
|
||||
it("recovers when API rate-limits once then succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
let n = 0;
|
||||
mockClientCreate
|
||||
.mockRejectedValueOnce(axiosRateLimitError(230020))
|
||||
.mockImplementation(() => Promise.resolve(okResponse(`om_recovered_${n++}`)));
|
||||
|
||||
const sendPromise = sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_recover", text: "recover" });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await sendPromise;
|
||||
expect(result.messageId).toMatch(/^om_recovered_/);
|
||||
// 1 rate-limited call + 1 successful retry
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rate-limit error message surfaces feishu_code for caller detection", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||||
|
||||
// Same pattern: allSettled attaches the handler synchronously before timers advance.
|
||||
const settled = Promise.allSettled([
|
||||
sendMessageFeishu({
|
||||
cfg: MOCK_CFG,
|
||||
to: "oc_err_msg",
|
||||
text: "check error message",
|
||||
}),
|
||||
]);
|
||||
await vi.runAllTimersAsync();
|
||||
const [result] = await settled;
|
||||
|
||||
expect(result.status).toBe("rejected");
|
||||
const error = result.status === "rejected" ? result.reason : null;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
// Error message must carry feishu_code so retry/circuit-breaker logic upstream can identify it
|
||||
expect((error as Error).message).toMatch(/230020/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Concurrent Feishu sends — timing and ordering", () => {
|
||||
it("concurrent sends complete faster than sequential would (all fire in parallel)", async () => {
|
||||
const CONCURRENCY = 5;
|
||||
const SIMULATED_DELAY_MS = 20;
|
||||
let n = 0;
|
||||
|
||||
mockClientCreate.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve(okResponse(`om_timed_${n++}`)), SIMULATED_DELAY_MS);
|
||||
}),
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_timed_${i}`, text: `msg ${i}` }),
|
||||
),
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(results).toHaveLength(CONCURRENCY);
|
||||
// Concurrent: should complete in roughly 1x delay, not CONCURRENCY * delay
|
||||
expect(elapsed).toBeLessThan(SIMULATED_DELAY_MS * CONCURRENCY);
|
||||
});
|
||||
|
||||
it("sends to multiple distinct targets resolve independently", async () => {
|
||||
const targets = ["oc_alpha", "oc_beta", "oc_gamma"];
|
||||
let n = 0;
|
||||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_target_${n++}`)));
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map((to) => sendMessageFeishu({ cfg: MOCK_CFG, to, text: "hello" })),
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(targets.length);
|
||||
for (const result of results) {
|
||||
expect(result.messageId).toBeTruthy();
|
||||
}
|
||||
expect(mockClientCreate).toHaveBeenCalledTimes(targets.length);
|
||||
});
|
||||
});
|
||||
323
extensions/feishu/src/send.retry.test.ts
Normal file
323
extensions/feishu/src/send.retry.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Unit tests for requestFeishuApi retry logic and getFeishuSendRateLimitCode.
|
||||
*
|
||||
* Tests the retry behaviour directly via requestFeishuApi with retryDelayMs:0
|
||||
* so no fake timers are needed. Related: issue #70879.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getFeishuSendRateLimitCode,
|
||||
getFeishuSendRateLimitCodeFromResponse,
|
||||
requestFeishuApi,
|
||||
} from "./comment-shared.js";
|
||||
|
||||
/** Build an AxiosError-shaped object for a given Feishu body error code (HTTP 400). */
|
||||
function axiosError(code: number) {
|
||||
return Object.assign(new Error("Request failed with status code 400"), {
|
||||
response: {
|
||||
status: 400,
|
||||
data: { code, msg: "feishu error" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AxiosError-shaped object for a Feishu Open API gateway HTTP 429
|
||||
* response (no Feishu business code in body — gateway short-circuits before
|
||||
* the message service).
|
||||
*/
|
||||
function http429Error() {
|
||||
return Object.assign(new Error("Request failed with status code 429"), {
|
||||
response: {
|
||||
status: 429,
|
||||
data: { msg: "Too Many Requests" },
|
||||
headers: { "x-ogw-ratelimit-reset": "1" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Use retryDelayMs: 0 throughout to keep tests fast with no real delays.
|
||||
const NO_DELAY = { retryDelayMs: 0 };
|
||||
|
||||
describe("getFeishuSendRateLimitCode", () => {
|
||||
it("returns 230020 for per-chat rate-limit AxiosError", () => {
|
||||
expect(getFeishuSendRateLimitCode(axiosError(230020))).toBe(230020);
|
||||
});
|
||||
|
||||
it("returns undefined for 230006 (not a transient rate limit)", () => {
|
||||
expect(getFeishuSendRateLimitCode(axiosError(230006))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-rate-limit code", () => {
|
||||
expect(getFeishuSendRateLimitCode(axiosError(230001))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a plain Error (no response shape)", () => {
|
||||
expect(getFeishuSendRateLimitCode(new Error("boom"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for null", () => {
|
||||
expect(getFeishuSendRateLimitCode(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeishuApi — success path", () => {
|
||||
it("resolves immediately on first attempt", async () => {
|
||||
const request = vi.fn().mockResolvedValue("ok");
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeishuApi — retry on rate-limit", () => {
|
||||
it("retries once and succeeds on second attempt (code 230020)", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(axiosError(230020))
|
||||
.mockResolvedValueOnce("ok-retry");
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok-retry");
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on code 230006", async () => {
|
||||
const request = vi.fn().mockRejectedValue(axiosError(230006));
|
||||
|
||||
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exhausts all retries and throws after 3 total attempts", async () => {
|
||||
const request = vi.fn().mockRejectedValue(axiosError(230020));
|
||||
|
||||
await expect(requestFeishuApi(request, "Feishu send failed", NO_DELAY)).rejects.toThrow(
|
||||
/Feishu send failed/,
|
||||
);
|
||||
// 1 initial attempt + 2 retries
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("wraps the final error with feishu_code in the message", async () => {
|
||||
const request = vi.fn().mockRejectedValue(axiosError(230020));
|
||||
|
||||
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/230020/);
|
||||
});
|
||||
|
||||
it("recovers on the third attempt after two rate-limit failures", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(axiosError(230020))
|
||||
.mockRejectedValueOnce(axiosError(230020))
|
||||
.mockResolvedValueOnce("ok-third");
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok-third");
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeishuApi — no retry for non-rate-limit errors", () => {
|
||||
it("throws immediately without retry for a non-rate-limit Feishu code", async () => {
|
||||
const request = vi.fn().mockRejectedValue(axiosError(230001));
|
||||
|
||||
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws immediately without retry for a plain Error", async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error("network failure"));
|
||||
|
||||
await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow(/network failure/);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeishuSendRateLimitCode — expanded rate-limit signals", () => {
|
||||
// 11232 is the tenant-level "create message service trigger rate limit"
|
||||
// (100/min, 5/sec). Same nature as 230020 (per-chat) but at a higher scope.
|
||||
it("returns 11232 for tenant-level message rate-limit AxiosError", () => {
|
||||
expect(getFeishuSendRateLimitCode(axiosError(11232))).toBe(11232);
|
||||
});
|
||||
|
||||
// HTTP 429 is the Feishu Open API gateway-level limit (app-wide quota);
|
||||
// it short-circuits before hitting the message service so the body has no
|
||||
// Feishu business code. We must detect it from response.status alone.
|
||||
it("returns 429 for gateway-level HTTP 429 with no business code in body", () => {
|
||||
expect(getFeishuSendRateLimitCode(http429Error())).toBe(429);
|
||||
});
|
||||
|
||||
it("prefers HTTP 429 over body code when both are present", () => {
|
||||
const err = Object.assign(new Error("Request failed with status code 429"), {
|
||||
response: {
|
||||
status: 429,
|
||||
data: { code: 230001, msg: "ignored" },
|
||||
},
|
||||
});
|
||||
expect(getFeishuSendRateLimitCode(err)).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeishuApi — retry on expanded rate-limit signals", () => {
|
||||
it("retries once and succeeds on second attempt (code 11232)", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(axiosError(11232))
|
||||
.mockResolvedValueOnce("ok-after-11232");
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok-after-11232");
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries once and succeeds on second attempt (HTTP 429)", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(http429Error())
|
||||
.mockResolvedValueOnce("ok-after-429");
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok-after-429");
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("exhausts retries on persistent 11232 and surfaces feishu_code", async () => {
|
||||
const request = vi.fn().mockRejectedValue(axiosError(11232));
|
||||
|
||||
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/11232/);
|
||||
// 1 initial attempt + 2 retries
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("exhausts retries on persistent HTTP 429 and surfaces http_status", async () => {
|
||||
const request = vi.fn().mockRejectedValue(http429Error());
|
||||
|
||||
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
// The error wrapper records http_status:429 in the JSON-encoded message.
|
||||
expect((err as Error).message).toMatch(/429/);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("recovers across mixed rate-limit signals (230020 → 11232 → ok)", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(axiosError(230020))
|
||||
.mockRejectedValueOnce(axiosError(11232))
|
||||
.mockResolvedValueOnce("ok-mixed");
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe("ok-mixed");
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// Feishu SDK can fulfill (no throw) with a rate-limit code in the body, e.g.
|
||||
// `{ code: 11232, msg: "..." }`. Same shape the typing path already handles
|
||||
// via getBackoffCodeFromResponse — see issue #28157.
|
||||
describe("getFeishuSendRateLimitCodeFromResponse — fulfilled body classification", () => {
|
||||
it("returns 230020 for a fulfilled per-chat rate-limit body", () => {
|
||||
expect(getFeishuSendRateLimitCodeFromResponse({ code: 230020, msg: "rate limit" })).toBe(
|
||||
230020,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 11232 for a fulfilled tenant-level rate-limit body", () => {
|
||||
expect(getFeishuSendRateLimitCodeFromResponse({ code: 11232, msg: "rate limit" })).toBe(11232);
|
||||
});
|
||||
|
||||
it("returns undefined for a successful body (code=0)", () => {
|
||||
expect(getFeishuSendRateLimitCodeFromResponse({ code: 0, data: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-rate-limit error body", () => {
|
||||
expect(getFeishuSendRateLimitCodeFromResponse({ code: 230001 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for null / non-object", () => {
|
||||
expect(getFeishuSendRateLimitCodeFromResponse(null)).toBeUndefined();
|
||||
expect(getFeishuSendRateLimitCodeFromResponse(undefined)).toBeUndefined();
|
||||
expect(getFeishuSendRateLimitCodeFromResponse("oops")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFeishuApi — retry on fulfilled rate-limit body (no throw)", () => {
|
||||
// Mirrors the typing-path fix from #28157: SDK resolves with the rate-limit
|
||||
// code instead of throwing, and the helper must classify before returning.
|
||||
it("retries when SDK fulfills with code 11232 then succeeds", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ code: 11232, msg: "rate limit", data: null })
|
||||
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_ok" } });
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toEqual({ code: 0, data: { message_id: "om_ok" } });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries when SDK fulfills with code 230020 then succeeds", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ code: 230020, msg: "rate limit" })
|
||||
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_ok2" } });
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_ok2");
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("exhausts retries when SDK keeps fulfilling 11232 and throws wrapped error", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ code: 11232, msg: "rate limit" });
|
||||
|
||||
const err = await requestFeishuApi(request, "Feishu send failed", NO_DELAY).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/Feishu send failed/);
|
||||
expect((err as Error).message).toMatch(/11232/);
|
||||
// 1 initial attempt + 2 retries
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry when SDK fulfills with a successful response (code=0)", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_first" } });
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_first");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when SDK fulfills with a non-rate-limit error code", async () => {
|
||||
// Non-retryable error codes pass through to assertFeishuMessageApiSuccess upstream.
|
||||
const fulfilled = { code: 230001, msg: "permission error" };
|
||||
const request = vi.fn().mockResolvedValue(fulfilled);
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect(result).toBe(fulfilled);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovers across thrown then fulfilled rate-limits (catch → try → ok)", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(axiosError(230020))
|
||||
.mockResolvedValueOnce({ code: 11232, msg: "rate limit" })
|
||||
.mockResolvedValueOnce({ code: 0, data: { message_id: "om_recovered" } });
|
||||
|
||||
const result = await requestFeishuApi(request, "prefix", NO_DELAY);
|
||||
expect((result as { data: { message_id: string } }).data.message_id).toBe("om_recovered");
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js";
|
||||
import { requestFeishuApi } from "./comment-shared.js";
|
||||
import type { MentionTarget } from "./mention-target.types.js";
|
||||
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
@@ -67,6 +67,11 @@ function isWithdrawnReplyError(err: unknown): boolean {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Wrapped error shape from createFeishuApiError: err.cause holds the original error.
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
if (cause && cause !== err) {
|
||||
return isWithdrawnReplyError(cause);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,17 +174,22 @@ async function sendReplyOrFallbackDirect(
|
||||
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: params.replyToMessageId },
|
||||
data: {
|
||||
content: params.content,
|
||||
msg_type: params.msgType,
|
||||
...(params.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
response = await requestFeishuApi(
|
||||
() =>
|
||||
client.im.message.reply({
|
||||
path: { message_id: params.replyToMessageId! },
|
||||
data: {
|
||||
content: params.content,
|
||||
msg_type: params.msgType,
|
||||
...(params.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
}),
|
||||
params.replyErrorPrefix,
|
||||
{ includeNestedErrorLogId: true },
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true });
|
||||
throw err;
|
||||
}
|
||||
if (replyTargetFallbackError) {
|
||||
throw replyTargetFallbackError;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { getFeishuUserAgent } from "./client.js";
|
||||
import { requestFeishuApi } from "./comment-shared.js";
|
||||
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
||||
import type { FeishuDomain } from "./types.js";
|
||||
|
||||
@@ -295,32 +296,44 @@ export class FeishuStreamingSession {
|
||||
const sendOptions = options ?? {};
|
||||
const sendMode = resolveStreamingCardSendMode(sendOptions);
|
||||
if (sendMode === "reply") {
|
||||
sendRes = await this.client.im.message.reply({
|
||||
path: { message_id: sendOptions.replyToMessageId! },
|
||||
data: {
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
sendRes = await requestFeishuApi(
|
||||
() =>
|
||||
this.client.im.message.reply({
|
||||
path: { message_id: sendOptions.replyToMessageId! },
|
||||
data: {
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
}),
|
||||
"Send card failed",
|
||||
);
|
||||
} else if (sendMode === "root_create") {
|
||||
// root_id is undeclared in the SDK types but accepted at runtime
|
||||
sendRes = await this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: Object.assign(
|
||||
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
|
||||
{ root_id: sendOptions.rootId },
|
||||
),
|
||||
});
|
||||
sendRes = await requestFeishuApi(
|
||||
() =>
|
||||
this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: Object.assign(
|
||||
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
|
||||
{ root_id: sendOptions.rootId },
|
||||
),
|
||||
}),
|
||||
"Send card failed",
|
||||
);
|
||||
} else {
|
||||
sendRes = await this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
},
|
||||
});
|
||||
sendRes = await requestFeishuApi(
|
||||
() =>
|
||||
this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
},
|
||||
}),
|
||||
"Send card failed",
|
||||
);
|
||||
}
|
||||
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
||||
throw new Error(`Send card failed: ${sendRes.msg}`);
|
||||
|
||||
@@ -55,6 +55,12 @@ export type RunIMessageCatchupParams = {
|
||||
* (including non-error drops, which mirrors the live pipeline).
|
||||
*/
|
||||
dispatchPayload: (message: IMessagePayload) => Promise<void>;
|
||||
/**
|
||||
* Called for `is_from_me=true` rows that catchup intentionally does not
|
||||
* dispatch. The live inbound path still needs to observe those rows so
|
||||
* self-chat reflected companion rows can be deduped.
|
||||
*/
|
||||
observeSkippedFromMePayload?: (message: IMessagePayload) => Promise<void> | void;
|
||||
runtime?: RuntimeLogger;
|
||||
/** Override clock for tests. */
|
||||
now?: () => number;
|
||||
@@ -277,6 +283,14 @@ export async function runIMessageCatchup(
|
||||
config,
|
||||
fetch: fetchFn,
|
||||
dispatch: dispatchFn,
|
||||
observeSkippedFromMe: async (row) => {
|
||||
const payload = payloadByGuid.get(row.guid);
|
||||
if (!payload) {
|
||||
warnLog(`imessage catchup: missing skipped from-me payload for guid=${row.guid}`);
|
||||
return;
|
||||
}
|
||||
await params.observeSkippedFromMePayload?.(payload);
|
||||
},
|
||||
log,
|
||||
warn: warnLog,
|
||||
...(params.now ? { now: params.now() } : {}),
|
||||
|
||||
@@ -261,6 +261,7 @@ describe("performIMessageCatchup", () => {
|
||||
|
||||
it("skips is_from_me rows but still advances the cursor past them", async () => {
|
||||
const dispatch = alwaysOk();
|
||||
const observeSkippedFromMe = vi.fn();
|
||||
const fetch = fetchOf([
|
||||
row({ guid: "A", rowid: 10, isFromMe: true }),
|
||||
row({ guid: "B", rowid: 11, isFromMe: false }),
|
||||
@@ -272,12 +273,16 @@ describe("performIMessageCatchup", () => {
|
||||
now,
|
||||
fetch,
|
||||
dispatch,
|
||||
observeSkippedFromMe,
|
||||
});
|
||||
|
||||
expect(summary.skippedFromMe).toBe(1);
|
||||
expect(summary.replayed).toBe(1);
|
||||
expect(summary.cursorAfter.lastSeenRowid).toBe(11);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(observeSkippedFromMe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guid: "A", rowid: 10, isFromMe: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops rows older than the maxAgeMinutes ceiling and advances past them", async () => {
|
||||
|
||||
@@ -326,6 +326,7 @@ export type PerformCatchupParams = {
|
||||
now?: number;
|
||||
fetch: CatchupFetchFn;
|
||||
dispatch: CatchupDispatchFn;
|
||||
observeSkippedFromMe?: (row: IMessageCatchupRow) => Promise<void> | void;
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
@@ -462,6 +463,13 @@ export async function performIMessageCatchup(
|
||||
continue;
|
||||
}
|
||||
if (row.isFromMe) {
|
||||
try {
|
||||
await params.observeSkippedFromMe?.(row);
|
||||
} catch (err) {
|
||||
params.warn?.(
|
||||
`imessage catchup: from-me observer failed for guid=${row.guid}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
summary.skippedFromMe += 1;
|
||||
highWatermarkMs = Math.max(highWatermarkMs, row.date);
|
||||
highWatermarkRowid = Math.max(highWatermarkRowid, row.rowid);
|
||||
|
||||
@@ -67,24 +67,24 @@ describe("deliverReplies", () => {
|
||||
[
|
||||
"chat_id:10",
|
||||
"first",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
client,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"chat_id:10",
|
||||
"second",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
client,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -112,26 +112,26 @@ describe("deliverReplies", () => {
|
||||
[
|
||||
"chat_id:20",
|
||||
"caption",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
maxBytes: 8192,
|
||||
client,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"chat_id:20",
|
||||
"",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/b.jpg",
|
||||
maxBytes: 8192,
|
||||
client,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -157,11 +157,11 @@ describe("deliverReplies", () => {
|
||||
[
|
||||
"chat_id:50",
|
||||
"durable hello",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
accountId: "acct-ignored",
|
||||
client,
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
expect(remember).toHaveBeenCalledWith("acct-5:chat_id:50", {
|
||||
@@ -191,11 +191,11 @@ describe("deliverReplies", () => {
|
||||
[
|
||||
"chat_id:60",
|
||||
"Visible reply",
|
||||
{
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
accountId: "acct-ignored",
|
||||
client,
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
expect(remember).toHaveBeenCalledWith("acct-6:chat_id:60", {
|
||||
@@ -204,10 +204,9 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records outbound text and message ids in sent-message cache (post-send only)", async () => {
|
||||
// Fix for #47830: remember() is called ONLY after each chunk is sent,
|
||||
// never with the full un-chunked text before sending begins.
|
||||
// Pre-send population widened the false-positive window in self-chat.
|
||||
it("records outbound text and message ids in sent-message cache after send", async () => {
|
||||
// Fix for #47830: monitor cache population remains per chunk, never the
|
||||
// full un-chunked text before sending begins.
|
||||
const remember = vi.fn();
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
sendMessageIMessageMock
|
||||
@@ -226,15 +225,13 @@ describe("deliverReplies", () => {
|
||||
sentMessageCache: { remember },
|
||||
});
|
||||
|
||||
// Only the two per-chunk post-send calls — no pre-send full-text call.
|
||||
expect(remember).toHaveBeenCalledTimes(2);
|
||||
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
|
||||
text: "first",
|
||||
messageId: "imsg-1",
|
||||
});
|
||||
expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", {
|
||||
text: "second",
|
||||
messageId: "imsg-2",
|
||||
expect(remember.mock.calls).toStrictEqual([
|
||||
["acct-3:chat_id:30", { text: "first", messageId: "imsg-1" }],
|
||||
["acct-3:chat_id:30", { text: "second", messageId: "imsg-2" }],
|
||||
]);
|
||||
expect(remember).not.toHaveBeenCalledWith("acct-3:chat_id:30", {
|
||||
text: "first|second",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,10 +55,6 @@ export async function deliverReplies(params: {
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
// Post-send cache population (#47830): caching happens after each chunk is sent,
|
||||
// not before. The window between send completion and cache write is sub-millisecond;
|
||||
// the next SQLite inbound poll is 1-2s away, so no echo can arrive before the
|
||||
// cache entry exists.
|
||||
sentMessageCache?.remember(scope, {
|
||||
text: sent.echoText ?? sent.sentText,
|
||||
messageId: sent.messageId,
|
||||
|
||||
@@ -6,6 +6,11 @@ type SentMessageLookup = {
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
type SentMessageLookupOptions = {
|
||||
skipIdShortCircuit?: boolean;
|
||||
includePendingText?: boolean;
|
||||
};
|
||||
|
||||
export type SentMessageCache = {
|
||||
remember: (scope: string, lookup: SentMessageLookup) => void;
|
||||
/**
|
||||
@@ -17,7 +22,11 @@ export type SentMessageCache = {
|
||||
* that will never match the GUID outbound IDs, but text matching is still
|
||||
* the right way to identify agent reply echoes.
|
||||
*/
|
||||
has: (scope: string, lookup: SentMessageLookup, skipIdShortCircuit?: boolean) => boolean;
|
||||
has: (
|
||||
scope: string,
|
||||
lookup: SentMessageLookup,
|
||||
options?: boolean | SentMessageLookupOptions,
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
// Echo arrival observed at ~2.2s on M4 Mac Mini (SQLite poll interval is the bottleneck).
|
||||
@@ -70,9 +79,21 @@ class DefaultSentMessageCache implements SentMessageCache {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
has(scope: string, lookup: SentMessageLookup, skipIdShortCircuit = false): boolean {
|
||||
has(
|
||||
scope: string,
|
||||
lookup: SentMessageLookup,
|
||||
options: boolean | SentMessageLookupOptions = false,
|
||||
): boolean {
|
||||
this.cleanup();
|
||||
if (hasPersistedIMessageEcho({ scope, ...lookup })) {
|
||||
const resolvedOptions =
|
||||
typeof options === "boolean" ? { skipIdShortCircuit: options } : options;
|
||||
if (
|
||||
hasPersistedIMessageEcho({
|
||||
scope,
|
||||
...lookup,
|
||||
includePendingText: resolvedOptions.includePendingText,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
@@ -89,7 +110,7 @@ class DefaultSentMessageCache implements SentMessageCache {
|
||||
const hasTextOnlyMatch =
|
||||
typeof textTimestamp === "number" &&
|
||||
(!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp);
|
||||
if (!skipIdShortCircuit && !hasTextOnlyMatch) {
|
||||
if (!resolvedOptions.skipIdShortCircuit && !hasTextOnlyMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,18 +193,67 @@ function resolveInboundEchoMessageIds(message: IMessagePayload): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function rememberIMessageSkippedFromMeForSelfChatDedupe(params: {
|
||||
accountId: string;
|
||||
message: IMessagePayload;
|
||||
bodyText: string;
|
||||
selfChatCache?: SelfChatCache;
|
||||
}): void {
|
||||
if (params.message.is_from_me !== true) {
|
||||
return;
|
||||
}
|
||||
const sender = params.message.sender?.trim();
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
const chatId = params.message.chat_id ?? undefined;
|
||||
const isGroup = Boolean(params.message.is_group);
|
||||
const chatIdentifierNormalized =
|
||||
normalizeIMessageHandle(params.message.chat_identifier ?? "") || undefined;
|
||||
const destinationCallerIdNormalized =
|
||||
normalizeIMessageHandle(params.message.destination_caller_id ?? "") || undefined;
|
||||
const senderNormalized = normalizeIMessageHandle(sender);
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
const lookup = {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
text: params.bodyText.trim(),
|
||||
createdAt,
|
||||
};
|
||||
const matchesSelfChatDestination =
|
||||
destinationCallerIdNormalized != null && destinationCallerIdNormalized === senderNormalized;
|
||||
const isSelfChat =
|
||||
!isGroup &&
|
||||
chatIdentifierNormalized != null &&
|
||||
senderNormalized === chatIdentifierNormalized &&
|
||||
matchesSelfChatDestination;
|
||||
const isAmbiguousSelfThread =
|
||||
!isGroup &&
|
||||
chatIdentifierNormalized != null &&
|
||||
senderNormalized === chatIdentifierNormalized &&
|
||||
destinationCallerIdNormalized == null;
|
||||
if (isSelfChat) {
|
||||
params.selfChatCache?.remember({ ...lookup, allowCreatedAtSkew: true });
|
||||
} else if (isAmbiguousSelfThread) {
|
||||
params.selfChatCache?.remember(lookup);
|
||||
}
|
||||
}
|
||||
|
||||
function hasIMessageEchoMatch(params: {
|
||||
echoCache: {
|
||||
has: (
|
||||
scope: string,
|
||||
lookup: { text?: string; messageId?: string },
|
||||
skipIdShortCircuit?: boolean,
|
||||
options?: boolean | { skipIdShortCircuit?: boolean; includePendingText?: boolean },
|
||||
) => boolean;
|
||||
};
|
||||
scope: string | readonly string[];
|
||||
text?: string;
|
||||
messageIds: string[];
|
||||
skipIdShortCircuit?: boolean;
|
||||
includePendingText?: boolean;
|
||||
}): boolean {
|
||||
// Outbound sends persist echo scopes keyed by whichever target shape was
|
||||
// used (chat_id, chat_guid, chat_identifier, or imessage:<handle>). Inbound
|
||||
@@ -232,7 +281,10 @@ function hasIMessageEchoMatch(params: {
|
||||
params.echoCache.has(
|
||||
scope,
|
||||
{ text: params.text, messageId: fallbackMessageId },
|
||||
params.skipIdShortCircuit,
|
||||
{
|
||||
skipIdShortCircuit: params.skipIdShortCircuit,
|
||||
includePendingText: params.includePendingText,
|
||||
},
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -349,7 +401,7 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
has: (
|
||||
scope: string,
|
||||
lookup: { text?: string; messageId?: string },
|
||||
skipIdShortCircuit?: boolean,
|
||||
options?: boolean | { skipIdShortCircuit?: boolean; includePendingText?: boolean },
|
||||
) => boolean;
|
||||
};
|
||||
selfChatCache?: SelfChatCache;
|
||||
@@ -453,6 +505,7 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
text: bodyText || undefined,
|
||||
messageIds: inboundMessageIds,
|
||||
skipIdShortCircuit: !hasInboundGuid,
|
||||
includePendingText: true,
|
||||
})
|
||||
) {
|
||||
return { kind: "drop", reason: "agent echo in self-chat" };
|
||||
@@ -649,6 +702,7 @@ export async function resolveIMessageInboundDecision(params: {
|
||||
scope: echoScope,
|
||||
text: bodyText || undefined,
|
||||
messageIds: inboundMessageIds,
|
||||
includePendingText: isSelfChat,
|
||||
})
|
||||
) {
|
||||
params.logVerbose?.(
|
||||
|
||||
@@ -117,6 +117,23 @@ describe("iMessage sent-message echo cache", () => {
|
||||
expect(cache.has(scope, { messageId: "id-only" })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps short-lived pending persisted echoes out of generic text matching", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||
const scope = "acct:imessage:+1555";
|
||||
|
||||
rememberPersistedIMessageEcho({ scope, text: "pending-send", ttlMs: 1_000, pending: true });
|
||||
expect(hasPersistedIMessageEcho({ scope, text: "pending-send" })).toBe(false);
|
||||
expect(
|
||||
hasPersistedIMessageEcho({ scope, text: "pending-send", includePendingText: true }),
|
||||
).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(
|
||||
hasPersistedIMessageEcho({ scope, text: "pending-send", includePendingText: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes persisted echoes written after an earlier empty lookup", () => {
|
||||
const cache = createSentMessageCache();
|
||||
const scope = "acct:imessage:+1555";
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "./inbound-dedupe.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
rememberIMessageSkippedFromMeForSelfChatDedupe,
|
||||
resolveIMessageReactionContext,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
@@ -728,14 +729,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageNowInner(rawMessage: IMessagePayload) {
|
||||
const message = await repairMessageConversationAnchor(rawMessage);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
function resolveIMessageInboundBodyText(message: IMessagePayload) {
|
||||
const messageText = (message.text ?? "").trim();
|
||||
|
||||
const attachments = includeAttachments ? (message.attachments ?? []) : [];
|
||||
const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
|
||||
const validAttachments = attachments.filter((entry) => {
|
||||
@@ -769,7 +764,28 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
: validAttachments.length
|
||||
? "<media:attachment>"
|
||||
: "";
|
||||
const bodyText = messageText || placeholder;
|
||||
return {
|
||||
messageText,
|
||||
bodyText: messageText || placeholder,
|
||||
validAttachments,
|
||||
rawMediaAttachments,
|
||||
effectiveAttachmentRoots,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMessageNowInner(rawMessage: IMessagePayload) {
|
||||
const message = await repairMessageConversationAnchor(rawMessage);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
messageText,
|
||||
bodyText,
|
||||
validAttachments,
|
||||
rawMediaAttachments,
|
||||
effectiveAttachmentRoots,
|
||||
} = resolveIMessageInboundBodyText(message);
|
||||
|
||||
// Approval reaction shortcut: if the inbound tapback resolves a pending
|
||||
// approval prompt, route it through the gateway and skip the normal
|
||||
@@ -1498,6 +1514,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
config: catchupCfg,
|
||||
includeAttachments,
|
||||
dispatchPayload: (message) => handleMessageNow(message, { advanceCatchupCursor: false }),
|
||||
observeSkippedFromMePayload: (message) => {
|
||||
const { bodyText } = resolveIMessageInboundBodyText(message);
|
||||
rememberIMessageSkippedFromMeForSelfChatDedupe({
|
||||
accountId: accountInfo.accountId,
|
||||
message,
|
||||
bodyText,
|
||||
selfChatCache,
|
||||
});
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
liveCatchupCursorAdvanceEnabled =
|
||||
|
||||
@@ -9,6 +9,8 @@ type PersistedEchoEntry = {
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
timestamp: number;
|
||||
expiresAt?: number;
|
||||
pending?: true;
|
||||
};
|
||||
|
||||
// 12h comfortably outlives the inbound replay guard window
|
||||
@@ -65,13 +67,24 @@ function remainingTtlMs(timestamp: number): number | undefined {
|
||||
return remaining > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
function resolveEntryTtlMs(entry: PersistedEchoEntry, ttlMs?: number): number | undefined {
|
||||
if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0) {
|
||||
return ttlMs;
|
||||
}
|
||||
return remainingTtlMs(entry.timestamp);
|
||||
}
|
||||
|
||||
function isLiveEntry(entry: PersistedEchoEntry, now = Date.now()): boolean {
|
||||
const cutoff = now - IMESSAGE_SENT_ECHOES_TTL_MS;
|
||||
return entry.timestamp >= cutoff && (entry.expiresAt == null || entry.expiresAt > now);
|
||||
}
|
||||
|
||||
function loadMirrorFromStore(): void {
|
||||
try {
|
||||
const cutoff = Date.now() - IMESSAGE_SENT_ECHOES_TTL_MS;
|
||||
mirror = openPersistedEchoStore()
|
||||
.entries()
|
||||
.map(({ value }) => value)
|
||||
.filter((entry) => entry.timestamp >= cutoff)
|
||||
.filter((entry) => isLiveEntry(entry))
|
||||
.toSorted((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-IMESSAGE_SENT_ECHOES_MAX_ENTRIES);
|
||||
} catch (err) {
|
||||
@@ -85,23 +98,30 @@ function readRecentEntries(): PersistedEchoEntry[] {
|
||||
return mirror ?? [];
|
||||
}
|
||||
|
||||
function persistEntry(entry: PersistedEchoEntry): void {
|
||||
const ttlMs = remainingTtlMs(entry.timestamp);
|
||||
if (!ttlMs) {
|
||||
return;
|
||||
function persistEntry(entry: PersistedEchoEntry, ttlMs?: number): string | undefined {
|
||||
const effectiveTtlMs = resolveEntryTtlMs(entry, ttlMs);
|
||||
if (!effectiveTtlMs) {
|
||||
return undefined;
|
||||
}
|
||||
const key = resolveIMessageSentEchoEntryKey(entry);
|
||||
try {
|
||||
openPersistedEchoStore().register(resolveIMessageSentEchoEntryKey(entry), entry, { ttlMs });
|
||||
openPersistedEchoStore().register(key, entry, {
|
||||
ttlMs: effectiveTtlMs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportFailure("write", err);
|
||||
return undefined;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function rememberPersistedIMessageEcho(params: {
|
||||
scope: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
}): void {
|
||||
ttlMs?: number;
|
||||
pending?: boolean;
|
||||
}): string | undefined {
|
||||
const text = normalizeText(params.text);
|
||||
const messageId = normalizeMessageId(params.messageId);
|
||||
const entry: PersistedEchoEntry = {
|
||||
@@ -109,22 +129,39 @@ export function rememberPersistedIMessageEcho(params: {
|
||||
timestamp: Date.now(),
|
||||
...(text ? { text } : {}),
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(params.pending ? { pending: true } : {}),
|
||||
};
|
||||
if (typeof params.ttlMs === "number" && Number.isFinite(params.ttlMs) && params.ttlMs > 0) {
|
||||
entry.expiresAt = entry.timestamp + params.ttlMs;
|
||||
}
|
||||
if (!entry.text && !entry.messageId) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
loadMirrorFromStore();
|
||||
persistEntry(entry);
|
||||
const cutoff = Date.now() - IMESSAGE_SENT_ECHOES_TTL_MS;
|
||||
const key = persistEntry(entry, params.ttlMs);
|
||||
mirror = [...(mirror ?? []), entry]
|
||||
.filter((candidate) => candidate.timestamp >= cutoff)
|
||||
.filter((candidate) => isLiveEntry(candidate))
|
||||
.slice(-IMESSAGE_SENT_ECHOES_MAX_ENTRIES);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function forgetPersistedIMessageEchoKey(key: string | undefined): void {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
openPersistedEchoStore().delete(key);
|
||||
} catch (err) {
|
||||
reportFailure("delete", err);
|
||||
}
|
||||
mirror = (mirror ?? []).filter((entry) => resolveIMessageSentEchoEntryKey(entry) !== key);
|
||||
}
|
||||
|
||||
export function hasPersistedIMessageEcho(params: {
|
||||
scope: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
includePendingText?: boolean;
|
||||
}): boolean {
|
||||
const text = normalizeText(params.text);
|
||||
const messageId = normalizeMessageId(params.messageId);
|
||||
@@ -138,7 +175,7 @@ export function hasPersistedIMessageEcho(params: {
|
||||
if (messageId && entry.messageId === messageId) {
|
||||
return true;
|
||||
}
|
||||
if (text && entry.text === text) {
|
||||
if (text && entry.text === text && (!entry.pending || params.includePendingText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,14 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installIMessageStateRuntimeForTest } from "../test-support/runtime.js";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
import { resolveIMessageInboundDecision } from "./inbound-processing.js";
|
||||
import { resetPersistedIMessageEchoCacheForTest } from "./persisted-echo-cache.js";
|
||||
import {
|
||||
rememberIMessageSkippedFromMeForSelfChatDedupe,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
import {
|
||||
rememberPersistedIMessageEcho,
|
||||
resetPersistedIMessageEchoCacheForTest,
|
||||
} from "./persisted-echo-cache.js";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
|
||||
/**
|
||||
@@ -664,6 +670,52 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("drops catchup-replayed self-chat reflection after observing skipped from-me companion", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-03T03:48:42Z"));
|
||||
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const text = "Exactly. I’ll treat assembled context as evidence only, not command authority.";
|
||||
|
||||
rememberIMessageSkippedFromMeForSelfChatDedupe({
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 86798,
|
||||
guid: "F502C080-08E9-4C3B-9650-31A0DF21FE3A",
|
||||
sender: "+15555550123",
|
||||
chat_identifier: "+15555550123",
|
||||
destination_caller_id: "+15555550123",
|
||||
text,
|
||||
created_at: "2026-06-03T03:48:28.922Z",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
bodyText: text,
|
||||
selfChatCache,
|
||||
});
|
||||
|
||||
const reflection = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 86799,
|
||||
guid: "1759A121-E3DB-41C2-B16A-AB6DE30570F2",
|
||||
sender: "+15555550123",
|
||||
chat_identifier: "+15555550123",
|
||||
destination_caller_id: "tel:+15555550123",
|
||||
text,
|
||||
created_at: "2026-06-03T03:48:28.738Z",
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: text,
|
||||
bodyText: text,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("does not apply sub-second skew matching to ambiguous normal DM rows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T05:34:00Z"));
|
||||
@@ -855,6 +907,70 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
});
|
||||
|
||||
describe("echo cache — text fallback for null-id inbound messages", () => {
|
||||
it("does not drop normal DM text from a pending pre-send marker", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const scope = "default:imessage:+15551234567";
|
||||
rememberPersistedIMessageEcho({
|
||||
scope,
|
||||
text: "same pending text",
|
||||
ttlMs: 155_000,
|
||||
pending: true,
|
||||
});
|
||||
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 12001,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "+15550001111",
|
||||
text: "same pending text",
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: "same pending text",
|
||||
bodyText: "same pending text",
|
||||
echoCache,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops self-chat reflected text from a pending pre-send marker", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const scope = "default:imessage:+15551234567";
|
||||
rememberPersistedIMessageEcho({
|
||||
scope,
|
||||
text: "pending self-chat reply",
|
||||
ttlMs: 155_000,
|
||||
pending: true,
|
||||
});
|
||||
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 12002,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "tel:+15551234567",
|
||||
text: "pending self-chat reply",
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: "pending self-chat reply",
|
||||
bodyText: "pending self-chat reply",
|
||||
echoCache,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
||||
});
|
||||
|
||||
it("still identifies echo via text when inbound message has id: null", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -669,6 +669,69 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("persists an echo marker before awaiting the bridge send result", async () => {
|
||||
let resolveRequest!: (value: Record<string, unknown>) => void;
|
||||
const client = {
|
||||
request: vi.fn(
|
||||
() =>
|
||||
new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}),
|
||||
),
|
||||
stop: vi.fn(async () => {}),
|
||||
} as unknown as IMessageRpcClient;
|
||||
|
||||
const send = sendMessageIMessage("+15551234567", "hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(getClientMocks(client).request).toHaveBeenCalled());
|
||||
expect(
|
||||
hasPersistedIMessageEcho({
|
||||
scope: "default:imessage:+15551234567",
|
||||
text: "hello",
|
||||
includePendingText: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
resolveRequest({ guid: "p:0/imsg-1" });
|
||||
await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-1" });
|
||||
});
|
||||
|
||||
it("keeps the pending echo marker alive for slow default-timeout sends", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-04T00:00:00Z"));
|
||||
let resolveRequest!: (value: Record<string, unknown>) => void;
|
||||
const client = {
|
||||
request: vi.fn(
|
||||
() =>
|
||||
new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}),
|
||||
),
|
||||
stop: vi.fn(async () => {}),
|
||||
} as unknown as IMessageRpcClient;
|
||||
|
||||
const send = sendMessageIMessage("+15551234567", "slow hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
expect(getClientMocks(client).request).toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(
|
||||
hasPersistedIMessageEcho({
|
||||
scope: "default:imessage:+15551234567",
|
||||
text: "slow hello",
|
||||
includePendingText: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
resolveRequest({ guid: "p:0/imsg-slow" });
|
||||
await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-slow" });
|
||||
});
|
||||
|
||||
it("resolves numeric chat.db ROWIDs to GUIDs for approval reaction binding", async () => {
|
||||
const client = createClient({ message_id: 12345 });
|
||||
const resolveMessageGuidImpl = vi.fn(async () => "p:0/resolved-guid");
|
||||
|
||||
@@ -28,7 +28,10 @@ import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { DEFAULT_IMESSAGE_SEND_TIMEOUT_MS } from "./constants.js";
|
||||
import { extractMarkdownFormatRuns } from "./markdown-format.js";
|
||||
import { rememberIMessageReplyCache } from "./monitor-reply-cache.js";
|
||||
import { rememberPersistedIMessageEcho } from "./monitor/persisted-echo-cache.js";
|
||||
import {
|
||||
forgetPersistedIMessageEchoKey,
|
||||
rememberPersistedIMessageEcho,
|
||||
} from "./monitor/persisted-echo-cache.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
type IMessageService,
|
||||
@@ -38,6 +41,8 @@ import {
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
type ParsedIMessageTarget = ReturnType<typeof parseIMessageTarget>;
|
||||
const MIN_PENDING_PERSISTED_ECHO_TTL_MS = 60_000;
|
||||
const PENDING_PERSISTED_ECHO_GRACE_MS = 5_000;
|
||||
|
||||
type IMessageSendOpts = {
|
||||
cliPath?: string;
|
||||
@@ -686,6 +691,13 @@ function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolvePendingPersistedEchoTtlMs(timeoutMs: number): number {
|
||||
return Math.max(
|
||||
MIN_PENDING_PERSISTED_ECHO_TTL_MS,
|
||||
Math.max(0, timeoutMs) + PENDING_PERSISTED_ECHO_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function isAttachmentCommandFallbackError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /(?:unknown|unrecognized|invalid|unsupported)\s+(?:command|subcommand)|not a recognized command|send-attachment.*(?:not found|unsupported|unavailable)|private api bridge.*unavailable|requires the imsg private api bridge|run imsg launch/iu.test(
|
||||
@@ -734,6 +746,7 @@ async function trySendAttachmentForTarget(params: {
|
||||
audioAsVoice?: boolean;
|
||||
replyToId?: string;
|
||||
echoText?: string;
|
||||
pendingEchoTtlMs: number;
|
||||
runCliJson: (args: readonly string[]) => Promise<Record<string, unknown>>;
|
||||
resolveMessageGuidImpl?: IMessageSendOpts["resolveMessageGuidImpl"];
|
||||
}): Promise<IMessageSendResult | null> {
|
||||
@@ -754,8 +767,21 @@ async function trySendAttachmentForTarget(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const echoScope = resolveOutboundEchoScope({
|
||||
accountId: params.accountId,
|
||||
target: params.target,
|
||||
});
|
||||
let result: Record<string, unknown>;
|
||||
let pendingEchoKey: string | undefined;
|
||||
try {
|
||||
if (echoScope) {
|
||||
pendingEchoKey = rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
text: params.echoText,
|
||||
ttlMs: params.pendingEchoTtlMs,
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
result = await params.runCliJson([
|
||||
"send-attachment",
|
||||
"--chat",
|
||||
@@ -768,6 +794,7 @@ async function trySendAttachmentForTarget(params: {
|
||||
"auto",
|
||||
]);
|
||||
} catch (error) {
|
||||
forgetPersistedIMessageEchoKey(pendingEchoKey);
|
||||
if (isAttachmentCommandFallbackError(error)) {
|
||||
return null;
|
||||
}
|
||||
@@ -776,6 +803,7 @@ async function trySendAttachmentForTarget(params: {
|
||||
const failure = resolveIMessageCliFailure(result);
|
||||
if (failure) {
|
||||
const error = new Error(failure);
|
||||
forgetPersistedIMessageEchoKey(pendingEchoKey);
|
||||
if (isAttachmentCommandFallbackError(error)) {
|
||||
return null;
|
||||
}
|
||||
@@ -790,10 +818,6 @@ async function trySendAttachmentForTarget(params: {
|
||||
resolveMessageGuidImpl: params.resolveMessageGuidImpl,
|
||||
});
|
||||
const messageId = resolvedId ?? (result.ok || result.success ? "ok" : "unknown");
|
||||
const echoScope = resolveOutboundEchoScope({
|
||||
accountId: params.accountId,
|
||||
target: params.target,
|
||||
});
|
||||
if (echoScope) {
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
@@ -863,6 +887,7 @@ export async function sendMessageIMessage(
|
||||
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
|
||||
const timeoutMs =
|
||||
opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS;
|
||||
const pendingEchoTtlMs = resolvePendingPersistedEchoTtlMs(timeoutMs);
|
||||
const region = opts.region?.trim() || account.config.region?.trim() || "US";
|
||||
const maxBytes =
|
||||
typeof opts.maxBytes === "number"
|
||||
@@ -929,6 +954,7 @@ export async function sendMessageIMessage(
|
||||
audioAsVoice: opts.audioAsVoice,
|
||||
...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}),
|
||||
echoText: attachmentEchoText,
|
||||
pendingEchoTtlMs,
|
||||
runCliJson,
|
||||
resolveMessageGuidImpl: opts.resolveMessageGuidImpl,
|
||||
});
|
||||
@@ -985,6 +1011,8 @@ export async function sendMessageIMessage(
|
||||
params.to = target.to;
|
||||
}
|
||||
|
||||
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
|
||||
|
||||
const client =
|
||||
opts.client ??
|
||||
(opts.createClient
|
||||
@@ -993,8 +1021,17 @@ export async function sendMessageIMessage(
|
||||
const shouldClose = !opts.client;
|
||||
let result: Record<string, unknown>;
|
||||
const sendStartedAtMs = Date.now();
|
||||
let pendingEchoKey: string | undefined;
|
||||
try {
|
||||
try {
|
||||
if (echoScope) {
|
||||
pendingEchoKey = rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
text: echoText,
|
||||
ttlMs: pendingEchoTtlMs,
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
result = await client.request<Record<string, unknown>>("send", params, {
|
||||
timeoutMs,
|
||||
});
|
||||
@@ -1053,7 +1090,6 @@ export async function sendMessageIMessage(
|
||||
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
|
||||
});
|
||||
}
|
||||
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
|
||||
if (echoScope) {
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
@@ -1109,6 +1145,9 @@ export async function sendMessageIMessage(
|
||||
...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
forgetPersistedIMessageEchoKey(pendingEchoKey);
|
||||
throw error;
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await client.stop();
|
||||
|
||||
@@ -173,9 +173,13 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
.mockImplementation(() => innerLineWebhookHandlerMock);
|
||||
unregisterHttpMock.mockReset();
|
||||
registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
|
||||
const key = params.target.path.startsWith("/")
|
||||
const withLeadingSlash = params.target.path.startsWith("/")
|
||||
? params.target.path
|
||||
: `/${params.target.path}`;
|
||||
const key =
|
||||
withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")
|
||||
? withLeadingSlash.slice(0, -1)
|
||||
: withLeadingSlash;
|
||||
const normalizedTarget = { ...params.target, path: key };
|
||||
const existing = params.targetsByPath.get(key) ?? [];
|
||||
params.targetsByPath.set(key, [...existing, normalizedTarget]);
|
||||
@@ -353,6 +357,39 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
secondMonitor.stop();
|
||||
});
|
||||
|
||||
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
webhookPath: "/line/webhook/",
|
||||
accountId: "default",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
});
|
||||
|
||||
const registration = requireWebhookRegistration();
|
||||
expect(registration.target.path).toBe("/line/webhook");
|
||||
|
||||
const route = requireRegisteredRoute();
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
|
||||
const req = Object.assign(createMockIncomingRequest([payload]), {
|
||||
method: "POST",
|
||||
headers: { "x-line-signature": signature },
|
||||
}) as unknown as IncomingMessage;
|
||||
const res = createRouteResponse();
|
||||
|
||||
await route.handler(req, res);
|
||||
|
||||
const bot = createLineBotMock.mock.results[0]?.value as {
|
||||
handleWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("acknowledges shared-path POST requests before matched event processing completes", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
normalizePluginHttpPath,
|
||||
normalizeWebhookPath,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
requestBodyErrorToText,
|
||||
resolveSingleWebhookTarget,
|
||||
@@ -337,7 +338,9 @@ export async function monitorLineProvider(
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
|
||||
const normalizedPath = normalizeWebhookPath(
|
||||
normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook",
|
||||
);
|
||||
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
|
||||
createLineNodeWebhookHandler({
|
||||
channelSecret: target.channelSecret,
|
||||
|
||||
118
extensions/llama-cpp/index.test.ts
Normal file
118
extensions/llama-cpp/index.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createPluginRegistryFixture,
|
||||
registerVirtualTestPlugin,
|
||||
} from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import {
|
||||
clearEmbeddingProviders,
|
||||
clearMemoryEmbeddingProviders,
|
||||
getRegisteredEmbeddingProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const memoryHostEmbeddingMocks = vi.hoisted(() => ({
|
||||
createLocalEmbeddingProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
|
||||
createLocalEmbeddingProvider: memoryHostEmbeddingMocks.createLocalEmbeddingProvider,
|
||||
}));
|
||||
|
||||
import llamaCppPlugin from "./index.js";
|
||||
import {
|
||||
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
|
||||
createLlamaCppEmbeddingProvider,
|
||||
formatLlamaCppSetupError,
|
||||
} from "./src/embedding-provider.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearEmbeddingProviders();
|
||||
clearMemoryEmbeddingProviders();
|
||||
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockReset();
|
||||
});
|
||||
|
||||
describe("llama.cpp provider plugin", () => {
|
||||
it("registers the local embedding provider through the generic SDK contract", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
id: "llama-cpp",
|
||||
name: "llama.cpp Provider",
|
||||
contracts: {
|
||||
embeddingProviders: ["local"],
|
||||
},
|
||||
register: llamaCppPlugin.register,
|
||||
});
|
||||
|
||||
const provider = getRegisteredEmbeddingProvider("local");
|
||||
expect(provider?.ownerPluginId).toBe("llama-cpp");
|
||||
expect(provider?.adapter).toMatchObject({
|
||||
id: "local",
|
||||
defaultModel: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
|
||||
transport: "local",
|
||||
});
|
||||
});
|
||||
|
||||
it("adapts the worker-backed local embedding provider", async () => {
|
||||
const close = vi.fn();
|
||||
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
|
||||
id: "local",
|
||||
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
|
||||
maxInputTokens: 2048,
|
||||
embedQuery: vi.fn(async () => [0.6, 0.8]),
|
||||
embedBatchInputs: vi.fn(async () => [[0.3, 0.4]]),
|
||||
embedBatch: vi.fn(async () => [[1, 0]]),
|
||||
close,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
|
||||
const provider = await createLlamaCppEmbeddingProvider(
|
||||
{
|
||||
config: {},
|
||||
provider: "local",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
|
||||
);
|
||||
|
||||
await expect(provider.embed("hello")).resolves.toEqual([0.6, 0.8]);
|
||||
await expect(
|
||||
provider.embedBatch([{ text: "doc" }], { signal: abortController.signal }),
|
||||
).resolves.toEqual([[0.3, 0.4]]);
|
||||
await provider.close?.();
|
||||
|
||||
expect(provider.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL);
|
||||
expect(provider.maxInputTokens).toBe(2048);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(memoryHostEmbeddingMocks.createLocalEmbeddingProvider).toHaveBeenCalledWith(
|
||||
{
|
||||
config: {},
|
||||
provider: "local",
|
||||
fallback: "none",
|
||||
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
|
||||
local: {
|
||||
modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
|
||||
},
|
||||
},
|
||||
{
|
||||
nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js",
|
||||
},
|
||||
);
|
||||
const workerProvider =
|
||||
await memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mock.results[0].value;
|
||||
expect(workerProvider.embedBatchInputs).toHaveBeenCalledWith([{ text: "doc" }], {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats missing runtime errors with the plugin install command", () => {
|
||||
const err = Object.assign(new Error("Cannot find package 'node-llama-cpp'"), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
|
||||
expect(formatLlamaCppSetupError(err)).toContain(
|
||||
"openclaw plugins install @openclaw/llama-cpp-provider",
|
||||
);
|
||||
});
|
||||
});
|
||||
11
extensions/llama-cpp/index.ts
Normal file
11
extensions/llama-cpp/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { llamaCppEmbeddingProviderAdapter } from "./src/embedding-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "llama-cpp",
|
||||
name: "llama.cpp Provider",
|
||||
description: "Local GGUF embeddings through node-llama-cpp",
|
||||
register(api) {
|
||||
api.registerEmbeddingProvider(llamaCppEmbeddingProviderAdapter);
|
||||
},
|
||||
});
|
||||
1778
extensions/llama-cpp/npm-shrinkwrap.json
generated
Normal file
1778
extensions/llama-cpp/npm-shrinkwrap.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
extensions/llama-cpp/openclaw.plugin.json
Normal file
17
extensions/llama-cpp/openclaw.plugin.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "llama-cpp",
|
||||
"name": "llama.cpp Provider",
|
||||
"description": "Local GGUF embeddings through node-llama-cpp.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"contracts": {
|
||||
"embeddingProviders": ["local"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user