mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Compare commits
199 Commits
38fdc4c934
...
fix/sqlite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec319a9a1 | ||
|
|
1878ca0820 | ||
|
|
c96a12d3c8 | ||
|
|
28a2e795da | ||
|
|
46f015a627 | ||
|
|
1fde4ae3b1 | ||
|
|
3ad7049cba | ||
|
|
190fd034d5 | ||
|
|
59366ca420 | ||
|
|
0771a8ab6f | ||
|
|
03c730c48f | ||
|
|
8c91980123 | ||
|
|
8fe67e4b70 | ||
|
|
a53a545542 | ||
|
|
41e56d56fc | ||
|
|
381c5e0762 | ||
|
|
e254346bc2 | ||
|
|
308114e148 | ||
|
|
fce002ad03 | ||
|
|
d5c8e90e28 | ||
|
|
2ecd1d3299 | ||
|
|
7f1a920a89 | ||
|
|
32acef01b3 | ||
|
|
95045b1d5b | ||
|
|
4f4cd2e8ae | ||
|
|
90b8b41c41 | ||
|
|
0b8aabe864 | ||
|
|
8de37e1ce4 | ||
|
|
20fa8a92a7 | ||
|
|
10830bc4a7 | ||
|
|
2b31ad2ee5 | ||
|
|
ed283490b5 | ||
|
|
bf368e7609 | ||
|
|
0756680421 | ||
|
|
639ff98509 | ||
|
|
0f05aff312 | ||
|
|
e9379ef22b | ||
|
|
b411c53248 | ||
|
|
154f439c81 | ||
|
|
7b82901e58 | ||
|
|
932034f1fc | ||
|
|
a0717ef61c | ||
|
|
f0237caf27 | ||
|
|
892602eaba | ||
|
|
79a8dec44d | ||
|
|
7098e335bf | ||
|
|
6d5061c234 | ||
|
|
286e5ffe07 | ||
|
|
158c4d7540 | ||
|
|
344e04b5d5 | ||
|
|
ec47d1cdd5 | ||
|
|
8c89d35a8a | ||
|
|
d358294f89 | ||
|
|
3480832614 | ||
|
|
e0ab71d3dc | ||
|
|
21b262f507 | ||
|
|
3a64302585 | ||
|
|
38f1db6d67 | ||
|
|
8f6f2617ec | ||
|
|
f4868b79e3 | ||
|
|
d3ab7e92ef | ||
|
|
acacd32415 | ||
|
|
0b26a1bca7 | ||
|
|
0bcdb9c0d1 | ||
|
|
946eed685d | ||
|
|
c219c62598 | ||
|
|
5483ff705f | ||
|
|
70a989a97a | ||
|
|
b7450f83a1 | ||
|
|
ff5667a582 | ||
|
|
d6bea4c5ac | ||
|
|
79896a24d9 | ||
|
|
a7d5ae1872 | ||
|
|
446a2b24c3 | ||
|
|
e4993ec00f | ||
|
|
90493ee8e2 | ||
|
|
60dcaa3cf5 | ||
|
|
b3b203bf67 | ||
|
|
0a4927d0b8 | ||
|
|
a61c94b1f1 | ||
|
|
a9f099d279 | ||
|
|
2fa60af960 | ||
|
|
07006943de | ||
|
|
9dc1694eb7 | ||
|
|
98ff56d70e | ||
|
|
03ccdb9fbc | ||
|
|
6d7b80fa1c | ||
|
|
409d1a7135 | ||
|
|
d31f4e2d62 | ||
|
|
e5e6cf04a2 | ||
|
|
4f8740029a | ||
|
|
9159b3bf8e | ||
|
|
eddf1c776d | ||
|
|
6ec579a0c2 | ||
|
|
87eaac4010 | ||
|
|
529282dcff | ||
|
|
b1fccd0605 | ||
|
|
287dee4593 | ||
|
|
b96c0d932f | ||
|
|
a46181f168 | ||
|
|
1b5cb4a0d3 | ||
|
|
9947a26768 | ||
|
|
2accf3875b | ||
|
|
76c8b36031 | ||
|
|
44fea3c94a | ||
|
|
c68938c19e | ||
|
|
a7c8b2a46a | ||
|
|
5a0d9d6326 | ||
|
|
7cee0bca0b | ||
|
|
7074cf8e23 | ||
|
|
26301f318f | ||
|
|
f49f5973b0 | ||
|
|
1e4ff80604 | ||
|
|
84dca54ef2 | ||
|
|
4a67e4b976 | ||
|
|
41ee6b1dd6 | ||
|
|
04f93c2fb4 | ||
|
|
3cdb87be86 | ||
|
|
17a285f298 | ||
|
|
c2d7b4a486 | ||
|
|
0b98aea71a | ||
|
|
114864185b | ||
|
|
1bd1483b62 | ||
|
|
a5ef086e3c | ||
|
|
a10faca06f | ||
|
|
380a8f140e | ||
|
|
34c3827290 | ||
|
|
54fe0e7f71 | ||
|
|
932d6ea8e5 | ||
|
|
d004b80c91 | ||
|
|
5820378b90 | ||
|
|
d5df1a1cd6 | ||
|
|
175cfe4846 | ||
|
|
85e5d486df | ||
|
|
b6cee3fc35 | ||
|
|
d48b9274d8 | ||
|
|
6d788a237c | ||
|
|
7ccbffcb1b | ||
|
|
2c92973398 | ||
|
|
ed4c4afc0f | ||
|
|
a462601f05 | ||
|
|
f472778717 | ||
|
|
7c1a83ff2e | ||
|
|
f8fcb35064 | ||
|
|
c0b05a2100 | ||
|
|
2a512025ad | ||
|
|
7f79bd8683 | ||
|
|
a4b09d72b9 | ||
|
|
58160094e8 | ||
|
|
c0c4156b6d | ||
|
|
3f66797578 | ||
|
|
f02c1209aa | ||
|
|
5056dd47ca | ||
|
|
97dde19577 | ||
|
|
7cbdebc4ed | ||
|
|
17795c6c4c | ||
|
|
6b25b78800 | ||
|
|
78b3f60dbd | ||
|
|
8f1ae5967e | ||
|
|
d82bfcecb1 | ||
|
|
5629c44547 | ||
|
|
a8bf14da84 | ||
|
|
a9f014e9df | ||
|
|
d76f2c0c3b | ||
|
|
f2a46b0661 | ||
|
|
0fa384c6f6 | ||
|
|
6d643ccd11 | ||
|
|
8b546facaf | ||
|
|
1f35ad12b3 | ||
|
|
3d4d30fd5a | ||
|
|
dd46fd36a3 | ||
|
|
85633eb615 | ||
|
|
2a3421a0da | ||
|
|
e38b8f6a20 | ||
|
|
646974b7d8 | ||
|
|
a86a1de849 | ||
|
|
be336cc1e4 | ||
|
|
8cecf2c7ea | ||
|
|
6af047c7f6 | ||
|
|
ac8338bb02 | ||
|
|
0188c541de | ||
|
|
97509ed1d7 | ||
|
|
432a5978b9 | ||
|
|
5f6a8083bf | ||
|
|
36d7ac31c2 | ||
|
|
aed3743630 | ||
|
|
28b1ea7c0d | ||
|
|
661c763b28 | ||
|
|
36a596aa9f | ||
|
|
c208a10619 | ||
|
|
e59e65be67 | ||
|
|
054e734e53 | ||
|
|
d007b9aba3 | ||
|
|
5d4868c036 | ||
|
|
8bf6206a3e | ||
|
|
1d3cfc4b01 | ||
|
|
1ff2ffa160 | ||
|
|
d07ba5f265 | ||
|
|
f789081bae |
@@ -111,9 +111,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every stable OpenClaw release ships the npm package and macOS app together.
|
||||
Beta releases normally ship npm/package artifacts first and skip mac app
|
||||
build/sign/notarize unless the operator requests mac beta validation.
|
||||
- Every stable OpenClaw release ships the npm package, macOS app, and signed
|
||||
Windows Hub installers together. Beta releases normally ship npm/package
|
||||
artifacts first and skip native app build/sign/notarize/promote unless the
|
||||
operator requests native beta validation.
|
||||
- Do not let the slower macOS signing/notary path block npm publication once
|
||||
the npm preflight has passed. Keep mac validation/publish running in
|
||||
parallel, publish npm from the successful npm preflight, then start published
|
||||
@@ -143,6 +144,17 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
- Stable Windows Hub release closeout requires the signed
|
||||
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
|
||||
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
|
||||
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
|
||||
workflow after the matching `openclaw/openclaw-windows-node` release exists;
|
||||
it verifies Authenticode signatures on Windows before uploading assets.
|
||||
- Website Windows Hub download links should target exact canonical
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
|
||||
stable release, or `releases/latest/download/...` only after verifying the
|
||||
redirect resolves to that same tag, so the installable signed Windows artifact
|
||||
is visible from both the GitHub release page and openclaw.ai.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
@@ -178,6 +190,13 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.D` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- To update an existing GitHub Release body, resolve the numeric release id and
|
||||
patch that resource with the notes file as the `body` field:
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
|
||||
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
|
||||
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
|
||||
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
|
||||
the tag lookup and `gh release view` can lag or show stale body text.
|
||||
- When preparing release notes, scan `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` for compatibility records
|
||||
with `warningStarts` or `removeAfter` within 7 days after the release date.
|
||||
|
||||
156
.github/workflows/ci-check-arm-testbox.yml
vendored
Normal file
156
.github/workflows/ci-check-arm-testbox.yml
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
name: Blacksmith ARM Testbox
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
|
||||
jobs:
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
136
.github/workflows/ci-check-testbox.yml
vendored
136
.github/workflows/ci-check-testbox.yml
vendored
@@ -139,139 +139,3 @@ jobs:
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
26
.github/workflows/crabbox-hydrate.yml
vendored
26
.github/workflows/crabbox-hydrate.yml
vendored
@@ -32,11 +32,11 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm/node_modules"
|
||||
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm/store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm-virtual-store"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm/virtual-store"
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
@@ -120,6 +120,19 @@ jobs:
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
reset_crabbox_pnpm_root() {
|
||||
local root="/var/tmp/openclaw-pnpm"
|
||||
rm -rf -- "$root"
|
||||
mkdir -p "$root"
|
||||
if [ -L "$root" ] || [ ! -d "$root" ] || [ ! -O "$root" ]; then
|
||||
echo "::error::Refusing unsafe pnpm cache root: $root"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
reset_crabbox_pnpm_root
|
||||
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -f node_modules
|
||||
fi
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
@@ -357,9 +370,10 @@ jobs:
|
||||
$env:XDG_CACHE_HOME = Join-Path $cacheRoot "cache"
|
||||
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
|
||||
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
|
||||
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $cacheRoot "openclaw-pnpm-node-modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $env:PNPM_CONFIG_MODULES_DIR ".pnpm"
|
||||
$pnpmCacheRoot = Join-Path $cacheRoot "openclaw-pnpm"
|
||||
$env:PNPM_CONFIG_STORE_DIR = Join-Path $pnpmCacheRoot "store"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $pnpmCacheRoot "node_modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $pnpmCacheRoot "virtual-store"
|
||||
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
|
||||
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
|
||||
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"
|
||||
|
||||
7
.github/workflows/docker-release.yml
vendored
7
.github/workflows/docker-release.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-alpha.*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
@@ -38,7 +39,11 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Docker alpha image publishing is disabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid release tag: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
126
.github/workflows/windows-node-release.yml
vendored
Normal file
126
.github/workflows/windows-node-release.yml
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
name: Windows Node Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing OpenClaw release tag to receive Windows Hub installers, for example v2026.6.1
|
||||
required: true
|
||||
type: string
|
||||
windows_node_tag:
|
||||
description: openclaw-windows-node release tag to promote, or latest
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: windows-node-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote_signed_windows_installers:
|
||||
name: Promote signed Windows installers
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
|
||||
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
|
||||
}
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
|
||||
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
|
||||
}
|
||||
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
|
||||
|
||||
- name: Download Windows Hub release installers
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
$tagArgs = @()
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest") {
|
||||
$tagArgs += $env:WINDOWS_NODE_TAG
|
||||
}
|
||||
gh release download @tagArgs `
|
||||
--repo openclaw/openclaw-windows-node `
|
||||
--pattern "OpenClawCompanion-Setup-*.exe" `
|
||||
--dir dist
|
||||
|
||||
$expected = @(
|
||||
"dist/OpenClawCompanion-Setup-x64.exe",
|
||||
"dist/OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
foreach ($file in $expected) {
|
||||
if (-not (Test-Path -LiteralPath $file)) {
|
||||
throw "Missing expected Windows installer: $file"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "$($_.Name) Authenticode signature was $($signature.Status)."
|
||||
}
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "$($_.Name) has no signer certificate."
|
||||
}
|
||||
[pscustomobject]@{
|
||||
File = $_.Name
|
||||
Signer = $signature.SignerCertificate.Subject
|
||||
Thumbprint = $signature.SignerCertificate.Thumbprint
|
||||
} | Format-List
|
||||
}
|
||||
|
||||
- name: Write SHA-256 manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
|
||||
Sort-Object Name |
|
||||
ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
|
||||
"$($hash.Hash.ToLowerInvariant()) $($_.Name)"
|
||||
} | Set-Content -Encoding utf8NoBOM -Path dist/OpenClawCompanion-SHA256SUMS.txt
|
||||
|
||||
- name: Upload to OpenClaw release
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload $env:RELEASE_TAG `
|
||||
dist/OpenClawCompanion-Setup-x64.exe `
|
||||
dist/OpenClawCompanion-Setup-arm64.exe `
|
||||
dist/OpenClawCompanion-SHA256SUMS.txt `
|
||||
--repo $env:GITHUB_REPOSITORY `
|
||||
--clobber
|
||||
|
||||
- name: Summary
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
run: |
|
||||
@"
|
||||
## Windows Hub installers promoted
|
||||
|
||||
OpenClaw release: $env:RELEASE_TAG
|
||||
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
|
||||
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
|
||||
"@ >> $env:GITHUB_STEP_SUMMARY
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -6,21 +6,49 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, trace first-output latency, and expose calmer composer controls. (#88772, #88825, #88998) Thanks @vincentkoc.
|
||||
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
|
||||
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
|
||||
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, and rollback snapshots so failures report bounded proof instead of stalling.
|
||||
- Plugin and skill installs now use an operator install policy instead of the old dangerous-code scanner path, with clearer doctor, CLI, ClawHub, and troubleshooting surfaces for package, archive, source, upload, and marketplace installs. (#89516) Thanks @joshavant.
|
||||
- Telegram, Feishu, Discord, WhatsApp, and outbound delivery paths got safer around duplicate transcript mirrors, Telegram admin writeback, streamed-final previews, approval allowlists, setup runtime state, poll modifiers, Discord voice errors, and internal progress traces. (#88973, #89626, #89812, #89035, #89814, #89813, #89601) Thanks @pgondhi987, @Petru2224, @zhangguiping-xydt, @codezz, and @takhoffman.
|
||||
- Chat, Control UI, Skill Workshop, Workboard, Android companion shell, and WebChat flows now preserve visible streaming text, reconcile completed sends, expose ACK timing, add Workboard keyboard movement, harden dialog accessibility, lazy-load usage views, keep current chat toggles working, and improve Android companion-first shell navigation. (#89801, #89777, #89802) Thanks @vincentkoc.
|
||||
- Security, policy, and config recovery now reject corrupt shell snapshots, unsupported policy keys, unsafe exec approval precheck environments, malformed script limits, and suspicious gateway startup configs while adding data-handling conformance checks. (#89701, #87074, #81488, #87056, #89480) Thanks @RomneyDa, @giodl73-repo, and @mmaps.
|
||||
- Gateway, agent, Codex, provider, model, and memory paths now recover session write-lock release failures, abandoned Codex app-server startups, stream-to-parent ACP spawns, custom-provider runtime fanout, bundled provider aliases, prompt-cache boundaries, Gemini stop sequences, Kimi cache markers, and watcher pressure warnings. (#89811, #89244) Thanks @RomneyDa and @takhoffman.
|
||||
- Release, CI, Docker, Crabbox/Testbox, package, and E2E validation lanes now bound more network calls, malformed numeric limits, process groups, cleanup leaks, package hydration paths, Windows installer publishing, release asset verification, and log drains so failures produce bounded proof instead of hanging.
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
|
||||
- Plugins/security: replace dangerous-code scanner enforcement with operator install policy, install-policy context, doctor checks, install/update CLI wiring, ClawHub metadata paths, and package/archive/source/upload lifecycle coverage. (#89516) Thanks @joshavant.
|
||||
- Policy: add data-handling conformance checks and reject unsupported policy keys. (#87056, #87074) Thanks @giodl73-repo.
|
||||
- Telegram/channels: show commentary and reasoning in progress drafts, share progress draft compositors across channel plugins, and keep Telegram polling stop/reset boundaries cheaper and more reliable.
|
||||
- UI/mobile: add Workboard keyboard movement controls, tighten Workboard card operations, improve Android companion-first shell UX, and document chat ACK timing metadata. (#89802) Thanks @vincentkoc.
|
||||
- Release metadata: align the root package, publishable plugin manifests, generated shrinkwraps, appcast, iOS, Android, macOS, Matrix plugin changelog, and docs/generated baselines with the 2026.6.2 beta train.
|
||||
- Release/packaging: promote Windows node installer publishing, require verified Windows release asset links, and document GitHub release-note edits.
|
||||
- Docs: refresh Windows Hub setup guidance and document Gateway, CLI, and plugin SDK helper contracts.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channels/outbound: keep channel sends durable when transcript mirroring fails, stop schema-padded poll modifiers from blocking normal sends, preserve WebChat `sessions_send` handoffs, preserve Discord channel-label suppression while hiding internal agent failure traces, match Discord libopus error shapes, and sanitize Discord tool progress scaffolding. (#89626, #89812, #89601) Thanks @Petru2224, @codezz, and @takhoffman.
|
||||
- Telegram/Feishu: require admin rights for Telegram target writeback, keep Telegram DM exec approval allowlists working with `ask:off`, prevent Telegram preview duplication across streaming modes, isolate verbose status after streamed finals, cancel clean restart stop timers, slow polling restart storms, and wire Feishu setup runtime setters. (#88973, #89035, #89813, #89814) Thanks @pgondhi987, @zhangguiping-xydt, and @takhoffman.
|
||||
- Chat/UI/Gateway: preserve visible chat stream text, clear stale stream buffers before terminal commits, reconcile completed sends, scroll pending sends into view, harden Workboard dialog accessibility, stabilize WebChat prompt-cache affinity, overlap chat catalog startup, render chat history incrementally, lazy-load usage dashboard, and report gateway health auth diagnostics. (#89337) Thanks @RomneyDa.
|
||||
- Agents/Codex/providers/models: release session write locks when prompt-release fence reads fail, retire abandoned Codex app-server startups, keep stream-to-parent ACP spawns registered, close Codex startup clients on timeout, recover bundled provider aliases, avoid custom-provider runtime fanout, preserve provider prompt-cache boundaries, forward Gemini stop sequences, and strip Kimi-incompatible Anthropic cache markers. (#89811) Thanks @takhoffman.
|
||||
- Memory/build/update: warn after startup watcher pressure checks, externalize optional Baileys image backends, restore and pin Canvas A2UI compatibility assets, keep plugin repair fetch failures nonblocking, restore Skill Workshop view switching, and keep the current chat toggle active after awaited session switches. (#89244) Thanks @RomneyDa.
|
||||
- Security/config/tooling: reject corrupt shell snapshots, suspicious gateway startup configs, malformed release/test/tooling/Docker/perf numeric limits, oversized audit responses, unsafe exec precheck env, and invalid pending-agent SQLite scaffold denials. (#89701, #89705, #89480, #81488) Thanks @RomneyDa and @mmaps.
|
||||
- Release/CI/E2E: restore package changelog extraction after the post-2026.6.1 version bump, keep hydrated pnpm modules under `node_modules` for ARM/Linux package lifecycle scripts, keep OpenAI live-cache prerequisites advisory while Anthropic prerequisites stay blocking, retry Windows Parallels background log appends on transient file-lock errors, bound candidate GitHub and cross-OS Discord fetches, harden ARM smoke/browser checks, show Docker build heartbeats, reset Crabbox pnpm hydrate state, and isolate Testbox/Docker/release journey artifacts.
|
||||
|
||||
## 2026.6.1
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, auth-profile failover, reasoning-tag cleanup, and media delivery retries. (#85798, #87484, #88129, #88136, #88141, #88162, #88182, #88924, #89220) Thanks @RomneyDa, @neeravmakwana, and @omarshahine.
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, QQBot, and iOS realtime Talk. (#88096, #88105, #88183, #88749, #88866, #88948, #88984, #89015, #88231) Thanks @omarshahine, @Jensenwgd, and @sliverp.
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, generated-content polling, provider-catalog failures, reasoning output, and model catalog paths before they can hang a run. (#88480, #88512, #88767, #88781, #88851, #88860, #89343, #89379, #89400) Thanks @vincentkoc, @charles-openclaw, @zz327455573, @849261680, and @XuZehan-iCenter.
|
||||
- Skills, Skill Workshop, and plugin loading now handle proposal review, stale disabled snapshots, support-file approvals, locale/routing fixes, and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173, #88734) Thanks @zeus1959 and @shakkernerd.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, typed presentation command actions, and external Copilot/Tokenjuice packaging add broader orchestration, integration, SDK, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117, #88721, #89336) Thanks @RomneyDa.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, cache transcript renders, prioritize first connect, and expose calmer composer controls and notification settings. (#74715, #88772, #88825, #88952, #88960, #88998, #89030, #89106) Thanks @VladyslavLevchuk, @vincentkoc, and @sallyom.
|
||||
- iMessage monitor state, inbound queues, Discord thread bindings, plugin install ledgers, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes moved toward SQLite-backed or cached state so restarts and hot paths do less repeated work. (#88794, #88797, #88866, #89075, #89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.
|
||||
- Release, CI, Docker, E2E, plugin install, update, doctor, diagnostics, and security lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, downgrade repair, and health probes so failures report bounded proof instead of stalling. (#84988, #87914, #87952, #88966, #89169, #89701, #89731) Thanks @LibraHo, @Niriakot, @MukundaKatta, and @RomneyDa.
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery; refresh ClawHub cards; and add ClawHub CLI, iMessage SSH-wrapper TCC, Android helper, diff-language, and host-local media-send guidance. (#79658, #88734, #88758, #88865, #89297) Thanks @simplyclever914, @shakkernerd, @vyctorbrzezowski, @TurboTheTurtle, @RomneyDa, and @Wang-Yeah623.
|
||||
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
|
||||
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
@@ -30,15 +58,19 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
|
||||
- iOS: support native iPad display layouts.
|
||||
- Android: add installed-app inspection commands, notification picker helpers, and updated-system-app classification.
|
||||
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
|
||||
- Workboard: wire task-backed board runs and show task comments in the edit modal.
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Code mode: add MCP API files and docs for code-mode integrations.
|
||||
- Gateway: support Tailscale Serve service names for local service routing.
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Plugin SDK: add typed presentation command actions and the bounded `resolve_exec_env` hook for plugin-provided exec environment contributions. (#88721)
|
||||
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
|
||||
- Providers: add MiniMax M3 model support. (#88860)
|
||||
- Tools/media: allow validated host-local text document media sends while keeping unsafe plain-text media sends blocked. (#79658) Thanks @simplyclever914.
|
||||
- Doctor: add disk space health checks and stabilize post-upgrade JSON probes.
|
||||
- Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)
|
||||
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
|
||||
@@ -54,50 +86,54 @@ Docs: https://docs.openclaw.ai
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
|
||||
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
|
||||
- Update: keep core updates nonblocking when missing external plugin repair downloads or soft plugin repair warnings would otherwise stall, pin post-core plugin compatibility to the downgraded core version, and still block installed active plugin payload smoke failures. (#84431, #87914, #87952) Thanks @TurboTheTurtle, @Niriakot, and @MukundaKatta.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, avoid duplicate generated-media fallbacks, and let mixed requests continue with summaries or other work while media renders in the background. (#89220) Thanks @omarshahine.
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
|
||||
- Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login and exhausted-failover recovery, clear legacy auto fallback pins, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#85798, #87484, #89181) Thanks @RomneyDa and @neeravmakwana.
|
||||
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
|
||||
- Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.
|
||||
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
|
||||
- CLI: harden CLI and plugin edge cases, and keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph. (#88896)
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Plugins: clarify plugin loader failure guidance and treat soft plugin repair warnings as nonfatal so missing or incompatible plugin packages point operators at the right repair path without blocking unrelated work. (#84431) Thanks @TurboTheTurtle.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, isolate provider catalog projections and web-provider factory failures, and keep private LLM-core declarations bundled so one bad plugin does not poison sibling runtime paths. (#77237, #88767, #88807, #89336) Thanks @vincentkoc and @RomneyDa.
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, single-job run-history names, startup cron retries, and legacy one-shot delete-after-run behavior. (#88285, #88294, #89075) Thanks @kip-claw.
|
||||
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
|
||||
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
|
||||
- Auto-reply: guard dispatcher failure-count probes so missing optional counters do not break SDK-typed recovery paths. (#89318) Thanks @Alix-007 and @takhoffman.
|
||||
- Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, avoid noisy gateway watcher warnings, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #89246, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.
|
||||
- Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows and `gemini-3.1-flash-lite`, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, switch direct Gemini reasoning to native mode, strip provider self-prefixes and Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #88781, #89343, #89379, #89400, #76612) Thanks @coder999999999, @BryanTegomoh, @vliuyt, @charles-openclaw, @zz327455573, @849261680, and @XuZehan-iCenter.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; keep iMessage typing active during tool work; allow RFC2544 benchmark ranges for QQBot token fetches; and retry WhatsApp QR login 408 timeouts. (#88183, #88948, #88984, #89015) Thanks @omarshahine, @Jensenwgd, and @sliverp.
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, corrupt shell snapshots, untrusted workspace setup-only channel loads, remote media reference overreads, trajectory export leaks, hooks-token auth reuse, and gateway WebSocket calls after close. (#86953, #87376, #88974, #89354, #89701) Thanks @hxy91819, @coygeek, @pgondhi987, and @RomneyDa.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, dist cache restores, Docker base-image/package cleanup, and mainline test flakes. (#84988, #88127, #88137, #88155, #88160, #88966, #89169) Thanks @LibraHo and @RomneyDa.
|
||||
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
|
||||
- Backup: accept root-relative hardlink targets during backup verification. (#89328) Thanks @abnershang.
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.
|
||||
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, cache chat transcript renders, record pending-send paint timing, show the Communication Notifications tab, honor Chromium executable overrides, and detect system Chromium for E2E. (#74715, #88952, #88960, #88998) Thanks @VladyslavLevchuk and @vincentkoc.
|
||||
- Channels: stop schema-padded poll modifiers from turning normal `send` actions into invalid poll sends. (#89601) Thanks @codezz and @takhoffman.
|
||||
- Channels: preserve long Feishu streaming replies, recover failed progress draft starts, send visible fallbacks when accepted Feishu turns produce no final reply, preserve external `sessions_send` routes, persist Discord thread bindings in SQLite, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896, #88749, #88803, #88866) Thanks @MonkeyLeeT.
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, surface disabled Codex plugin routes in doctor lint, respect explicit PI runtime policy, report runtime tool-schema and gateway health credential errors, clear recovered embedded-run activity, migrate voice-call call logs through doctor, and keep post-upgrade JSON stable. (#88731, #88761, #88820, #88288, #89731) Thanks @brokemac79, @openperf, and @RomneyDa.
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, support Tailscale Serve service names, guard Browser/Chrome pending attach aborts, and carry session UUIDs on interactive dispatch events. (#88305) Thanks @rohitjavvadi.
|
||||
- Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.
|
||||
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
|
||||
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
|
||||
|
||||
336
appcast.xml
336
appcast.xml
@@ -2,6 +2,133 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026060190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
|
||||
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
|
||||
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
|
||||
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
|
||||
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
|
||||
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
|
||||
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
|
||||
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
|
||||
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
|
||||
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
|
||||
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
|
||||
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
|
||||
<li>iOS: support native iPad display layouts.</li>
|
||||
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
|
||||
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
|
||||
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
|
||||
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
|
||||
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
|
||||
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
|
||||
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
|
||||
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
|
||||
<li>Providers: add MiniMax M3 model support. (#88860)</li>
|
||||
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
|
||||
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
|
||||
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
|
||||
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
|
||||
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
|
||||
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
|
||||
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
|
||||
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
|
||||
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
|
||||
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
|
||||
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
|
||||
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
|
||||
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
|
||||
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
|
||||
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
|
||||
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
|
||||
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
|
||||
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
|
||||
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
|
||||
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
|
||||
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
|
||||
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
|
||||
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
|
||||
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
|
||||
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
|
||||
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
|
||||
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
|
||||
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
|
||||
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
|
||||
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
|
||||
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
|
||||
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
|
||||
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
|
||||
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
|
||||
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
|
||||
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
|
||||
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
|
||||
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
|
||||
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
|
||||
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
|
||||
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
|
||||
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
|
||||
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
|
||||
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
|
||||
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
|
||||
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
|
||||
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
|
||||
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
|
||||
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
|
||||
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
|
||||
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
|
||||
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
|
||||
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
|
||||
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
|
||||
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
|
||||
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
|
||||
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
|
||||
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
|
||||
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
|
||||
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
|
||||
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
|
||||
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
|
||||
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
|
||||
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
|
||||
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
|
||||
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
|
||||
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
|
||||
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
|
||||
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
|
||||
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.28</title>
|
||||
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
|
||||
@@ -113,214 +240,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.26</title>
|
||||
<pubDate>Wed, 27 May 2026 12:24:26 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052690</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.26</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.</li>
|
||||
<li>Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.</li>
|
||||
<li>More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.</li>
|
||||
<li>Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.</li>
|
||||
<li>Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.</li>
|
||||
<li>Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.</li>
|
||||
<li>More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows Scheduled Tasks, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.</li>
|
||||
<li>Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.</li>
|
||||
<li>Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.</li>
|
||||
<li>Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)</li>
|
||||
<li>Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual <code>/approve</code> commands. (#85894, #85952, #85477)</li>
|
||||
<li>Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)</li>
|
||||
<li>TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)</li>
|
||||
<li>Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)</li>
|
||||
<li>Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)</li>
|
||||
<li>Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.</li>
|
||||
<li>Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)</li>
|
||||
<li>Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.</li>
|
||||
<li>Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.</li>
|
||||
<li>Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.</li>
|
||||
<li>Voice: expose shared realtime consult question matching, speakable-result extraction, and alias-aware forced-consult coordination through the realtime voice SDK, then reuse it in Gateway Talk, Voice Call, and Discord voice paths.</li>
|
||||
<li>Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.</li>
|
||||
<li>Cron: default <code>cron.maxConcurrentRuns</code> to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.</li>
|
||||
<li>QA-Lab: add <code>qa coverage --match <query></code> so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.</li>
|
||||
<li>Discord/model picker: surface an alpha-bucket select (e.g. <code>A–G (12) · H–N (18) · O–Z (5)</code>) when the provider list or a provider's model list exceeds 25 items, so configs with <code>provider/*</code> wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter.</li>
|
||||
<li>Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.</li>
|
||||
<li>Build: include <code>ui:build</code> in the <code>full</code> and <code>ciArtifacts</code> profiles of <code>scripts/build-all.mjs</code> so <code>pnpm build</code> always rebuilds <code>dist/control-ui</code> after <code>tsdown</code> cleans <code>dist</code>, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)</li>
|
||||
<li>iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.</li>
|
||||
<li>Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)</li>
|
||||
<li>Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Memory/security: reject prompt-like text submitted through the explicit <code>memory_store</code> tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)</li>
|
||||
<li>Gateway/security: enable the default auth rate limiter for remote non-browser and HTTP gateway auth failures when <code>gateway.auth.rateLimit</code> is unset, while preserving the loopback exemption. (#87148)</li>
|
||||
<li>Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack <code>allowFrom</code> sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.</li>
|
||||
<li>Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.</li>
|
||||
<li>TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)</li>
|
||||
<li>Plugin commands/SDK: preserve plugin LLM command auth, bind native plugin command dispatch to the host agent's LLM auth, keep <code>onDiagnosticEvent</code> exports discoverable through <code>Function.name</code>, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)</li>
|
||||
<li>Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama <code>top_p</code>, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)</li>
|
||||
<li>Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, avoid cloning live-switch and lifecycle session caches on read paths, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest <code>PATH</code>, and bootstrap raw AWS macOS Node/pnpm commands through <code>/usr/bin/env</code>. (#86997)</li>
|
||||
<li>Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.</li>
|
||||
<li>Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.</li>
|
||||
<li>Agents/replay: repair legacy tool results before replay, preserve <code>sessions_spawn</code> transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.</li>
|
||||
<li>Agents/sessions: handle active-fallback failures in <code>sessions_send</code> so fallback routing reports the real failure and does not leave callers with an ambiguous dropped send. (#86638)</li>
|
||||
<li>Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)</li>
|
||||
<li>Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.</li>
|
||||
<li>Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.</li>
|
||||
<li>CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.</li>
|
||||
<li>Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li>Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.</li>
|
||||
<li>Telegram/network: treat <code>ENETDOWN</code> as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.</li>
|
||||
<li>Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.</li>
|
||||
<li>iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.</li>
|
||||
<li>WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect <code>OPENCLAW_HOME</code>, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.</li>
|
||||
<li>Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)</li>
|
||||
<li>Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)</li>
|
||||
<li>Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.</li>
|
||||
<li>Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.</li>
|
||||
<li>Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm <code>min-release-age</code> installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, make release/plugin prerelease checks fail closed instead of hanging or false-greening, and use host-visible Crabbox local work roots for Docker-backed proof. (#85491)</li>
|
||||
<li>Windows daemon: keep Scheduled Task gateway launches running on battery power and avoid workgroup-machine prompts for a domain user during task installation. (#59299)</li>
|
||||
<li>Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)</li>
|
||||
<li>Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)</li>
|
||||
<li>Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.</li>
|
||||
<li>Agents/sessions: include visibility metadata on restricted <code>sessions_list</code> results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.</li>
|
||||
<li>Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid <code>discovery.wideArea.domain</code> and <code>dns setup --domain</code> values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.</li>
|
||||
<li>Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.</li>
|
||||
<li>Telegram: treat <code>/command@TargetBot</code> bot-command entities as explicit mentions for the addressed bot so <code>requireMention</code> groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang.</li>
|
||||
<li>CI: bound Docker/Bash E2E tarball npm installs with <code>OPENCLAW_E2E_NPM_INSTALL_TIMEOUT</code> so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install.</li>
|
||||
<li>CI: fail Parallels npm-update smoke jobs after the guest command timeout and cleanup backstop instead of only logging a timeout line.</li>
|
||||
<li>CI: bound kitchen-sink RPC HTTP probes so stalled gateway readiness or response bodies fail and retry instead of wedging the walker.</li>
|
||||
<li>CI: keep <code>OPENCLAW_TESTBOX=1 pnpm check:changed</code> delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command.</li>
|
||||
<li>CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged <code>git fetch</code>.</li>
|
||||
<li>CI: send KILL after the TERM grace period for Bun global install smoke command timeouts so trapped <code>openclaw</code> child processes cannot wedge the scheduled install smoke.</li>
|
||||
<li>iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under <code>~/Library/Messages/Attachments</code> (including the wildcard <code>/Users/*/Library/Messages/Attachments</code> root) are read through the existing inbound path policy instead of being rejected as <code>path-not-allowed</code>. Literal <code>localRoots</code> stays workspace-scoped. Fixes #30170. (#86569)</li>
|
||||
<li>QQ Bot: respect <code>OPENCLAW_HOME</code> for outbound media path resolution so <code><qqmedia></code> sends no longer silently fail when <code>HOME</code> and <code>OPENCLAW_HOME</code> differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.</li>
|
||||
<li>Update: report the primary malformed <code>openclaw.extensions</code> payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.</li>
|
||||
<li>Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.</li>
|
||||
<li>Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant.</li>
|
||||
<li>IRC: store inbound channel routes with the canonical <code>channel:#name</code> target and join transient channel sends before writing. (#85906) Thanks @Kailigithub.</li>
|
||||
<li>Usage: surface unknown all-zero model pricing as missing cost entries instead of a confident <code>$0</code> total. (#85882) Thanks @MichaelZelbel.</li>
|
||||
<li>Agents/Codex: honor yolo app-server approval policy only for the full <code>never</code> plus <code>danger-full-access</code> case. (#85909) Thanks @earlvanze.</li>
|
||||
<li>Gateway/Gmail: clear Gmail watcher renewal intervals on re-entry so hot reloads do not leak lifecycle timers. (#82947) Thanks @SebTardif.</li>
|
||||
<li>Logging: exit cleanly on broken stdout/stderr pipes without masking existing failure exit codes. (#80059) Thanks @pavelzak.</li>
|
||||
<li>Gateway/security: escape transcript metadata field names while extracting oversized session line prefixes. (#85934) Thanks @SebTardif.</li>
|
||||
<li>Plugins/security: validate manifest model pattern regexes with the safe-regex compiler so unsafe patterns are ignored before matching. (#86046) Thanks @SebTardif.</li>
|
||||
<li>Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct <code>discord.com</code> connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker.</li>
|
||||
<li>Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure.</li>
|
||||
<li>Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88.</li>
|
||||
<li>Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc.</li>
|
||||
<li>Gateway: emit plugin <code>session_end</code>/<code>session_start</code> hooks when <code>agent.send</code> rotates or replaces a session id, keeping hook lifecycle state aligned with <code>sessions.changed</code> notifications. Fixes #83507. (#85875) Thanks @brokemac79.</li>
|
||||
<li>OpenShell/SSH: reject malformed generated exec commands before sandbox/session setup so unresolved workflow placeholders fail fast instead of reaching the remote shell. Fixes #72373. Thanks @brokemac79.</li>
|
||||
<li>Google: stop normalizing <code>gemini-3.1-flash-lite</code> to the retired preview endpoint and update Flash Lite alias guidance to the GA model id. Fixes #86151. (#86240) Thanks @SebTardif.</li>
|
||||
<li>Installer: make Alpine apk installs cover Git, verify the Node runtime floor, try <code>nodejs-current</code>, and report Alpine version guidance when repositories only provide older Node packages.</li>
|
||||
<li>Agents/status: prefer the active Claude CLI OAuth auth label over an unused Anthropic env API-key label for equivalent runtime aliases. Fixes #80184. (#86570) Thanks @brokemac79.</li>
|
||||
<li>Agents/media: send direct fallback for generated media still missing after an active requester wake fails. (#85489) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents: derive overflow compaction budgets from provider-reported and synthetic over-budget token counts so confirmed context overflows compact before retrying. (#70473) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/Codex: recover Codex context-window prompt errors through overflow compaction and surface reset guidance when recovery is exhausted. (#85542) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/Codex: allow Codex app-server runs to bootstrap from <code>CODEX_API_KEY</code> or <code>OPENAI_API_KEY</code> when no Codex auth profile is configured.</li>
|
||||
<li>Agents/Codex: keep selected Codex runtime routing on OpenAI-Codex while preserving direct OpenAI API-key compaction fallback. (#86408) Thanks @funmerlin and @VACInc.</li>
|
||||
<li>Agent transcript: include OpenClaw agent session logs when finding local transcript candidates.</li>
|
||||
<li>Crabbox: bootstrap raw AWS macOS shell commands wrapped in absolute <code>time</code> paths so RSS probes can run Node and pnpm on fresh macOS runners.</li>
|
||||
<li>Crabbox: bootstrap raw AWS macOS shell commands even when setup statements precede Node or pnpm usage.</li>
|
||||
<li>TUI/local: skip unnecessary secret resolution, gateway model catalog loading, bootstrap, and skill scans in explicit local-model runs so startup reaches the model request faster.</li>
|
||||
<li>Sessions/doctor: load large session stores without clone amplification during read-only doctor checks and reclaim stale <code>sessions.json.*.tmp</code> sidecars. Fixes #56827. Thanks @openperf.</li>
|
||||
<li>Tests: clean successful plugin gateway gauntlet isolated temp roots while keeping an explicit preservation switch for failed/debug runs.</li>
|
||||
<li>Plugins/perf: reuse derived plugin metadata snapshots for the lifetime of the process so reply-time skill setup no longer rescans plugin metadata on every turn.</li>
|
||||
<li>Discord/OpenAI voice: keep wake-name master consults using the current speaker context after ignored ambient transcripts and shorten the default capture silence grace.</li>
|
||||
<li>Doctor: skip redundant Gateway restart prompts when a recent supervisor restart leaves the Gateway healthy. Fixes #86518. (#86533) Thanks @liaoyl830.</li>
|
||||
<li>Cron: restore suspended cron lanes to the configured/default concurrency instead of falling back to one after quota or circuit-breaker auto-resume.</li>
|
||||
<li>Gateway: keep session-only Control UI tool-start mirrors flowing during diagnostic queue pressure instead of silently dropping non-terminal tool updates.</li>
|
||||
<li>Agents/memory: return optional not-found context for missing date-only daily memory reads instead of logging benign first-run <code>ENOENT</code> failures. Fixes #82928. Thanks @galiniliev.</li>
|
||||
<li>Discord: merge streamed text captions into following media block replies so captions and attachments send as one message. (#86487) Thanks @neeravmakwana.</li>
|
||||
<li>Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.</li>
|
||||
<li>Discord/OpenAI voice: accept broader edge-position fuzzy wake-name transcripts while keeping ambient speech gated.</li>
|
||||
<li>Discord/OpenAI voice: accept longer leading wake-name mistranscripts such as "Open Club" for OpenClaw.</li>
|
||||
<li>Agents/OpenAI-compatible: stop ModelStudio-compatible chat requests before sending system/tool-only payloads that have no usable user or assistant turn. (#86177) Thanks @TurboTheTurtle.</li>
|
||||
<li>Gateway/plugins: reuse plugin package realpath checks while building installed plugin indexes so startup avoids repeated filesystem resolution work.</li>
|
||||
<li>Kilo Gateway: send string <code>stop</code> sequences as arrays so Kilo accepts OpenAI-compatible chat completions. (#86461) Thanks @SebTardif.</li>
|
||||
<li>Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.</li>
|
||||
<li>Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)</li>
|
||||
<li>Agents: release embedded-attempt session locks from outer teardown so post-prompt exceptions cannot wedge later requests behind <code>SessionWriteLockTimeoutError</code>. Fixes #86014. Thanks @openperf.</li>
|
||||
<li>Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.</li>
|
||||
<li>Sessions: skip metadata-only entries during QMD-slugified session lookup so one incomplete row does not block transcript hit resolution. (#86327) Thanks @abnershang.</li>
|
||||
<li>Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong.</li>
|
||||
<li>Image tool: keep config-backed custom-provider API keys usable for auto-discovered vision models, including deferred image-tool execution without env keys or auth profiles. (#85733)</li>
|
||||
<li>Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz.</li>
|
||||
<li>Gateway: clear the runtime config snapshot before <code>SIGUSR1</code> in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter.</li>
|
||||
<li>Models: show OAuth delegation markers as configured <code>models.json</code> auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.</li>
|
||||
<li>Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.</li>
|
||||
<li>Cron: preserve unsupported persisted cron payload rows during routine store writes while keeping those rows non-runnable. Fixes #84922. (#86415) Thanks @IWhatsskill.</li>
|
||||
<li>Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.</li>
|
||||
<li>Security/Audit: flag webhook <code>hooks.token</code> reuse of active Gateway password auth in <code>openclaw security audit</code> while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.</li>
|
||||
<li>QQBot: derive the outbound reply watchdog from configured agent and provider timeouts so slow local model replies are not cut off at five minutes. Fixes #85267. (#85271) Thanks @SymbolStar.</li>
|
||||
<li>Agents/heartbeat: stop heartbeat turns after the first valid <code>heartbeat_respond</code> so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.</li>
|
||||
<li>Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.</li>
|
||||
<li>Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.</li>
|
||||
<li>Memory: abort sync instead of downgrading an existing semantic vector index to FTS-only when the configured embedding provider is temporarily unavailable. (#85704) Thanks @yaaboo-gif.</li>
|
||||
<li>Telegram: propagate forum topic names through the account-scoped topic cache for native command context and topic create/edit actions. (#86299) Thanks @SebTardif.</li>
|
||||
<li>Slack: keep downloaded read-only files out of reply media so Slack file reads do not echo files back to the conversation. (#86318) Thanks @neeravmakwana.</li>
|
||||
<li>Cron: accept leading-plus relative durations such as <code>+5m</code> for one-shot <code>--at</code> schedules. (#86341) Thanks @mushuiyu886.</li>
|
||||
<li>Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev.</li>
|
||||
<li>Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily.</li>
|
||||
<li>QA/diagnostics: add a collector-backed OpenTelemetry smoke lane, make the OTLP payload leak check scenario-aware, and keep source QA builds from failing on optional dependency imports resolved through pnpm's temp module path.</li>
|
||||
<li>Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run <code>pnpm check:changed</code> from the intended diff.</li>
|
||||
<li>xAI/LM Studio: avoid buffering ordinary bracketed or <code>final</code> prose until stream completion while watching for plain-text tool-call fallbacks.</li>
|
||||
<li>Doctor: warn and continue when the cron job store exists but cannot be read so later health checks still run. Fixes #86102. (#86384) Thanks @1052326311.</li>
|
||||
<li>Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord: restore bare numeric channel IDs for outbound message-tool sends while keeping explicit DM targets unambiguous. (#86571) Thanks @joshavant.</li>
|
||||
<li>Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.</li>
|
||||
<li>Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that <code>pnpm build</code> includes <code>ui:build</code>.</li>
|
||||
<li>Tests: give QA config mutation RPCs enough native Windows budget to finish gateway config writes and restart settle after hot scenario runs.</li>
|
||||
<li>Tests: keep the gateway restart-inflight QA scenario focused on restart recovery on native Windows by allowing expected embedded prompt handoff errors and using the Windows-safe timeout budget.</li>
|
||||
<li>QA-Lab: make the synthetic OpenAI provider honor generic <code>reply exactly:</code> directives after required kickoff reads so restart-recovery scenarios do not fall through to generic repo-summary prose.</li>
|
||||
<li>Gateway: abort active <code>agent</code> RPC runs during forced restart shutdown so stale in-process turns cannot keep writing a session after the Gateway lifecycle restarts.</li>
|
||||
<li>Crabbox: sync clean sparse worktrees through a temporary full checkout even when reusing an existing lease so tracked build-time files are not omitted.</li>
|
||||
<li>Build: route <code>scripts/ui.js</code> through the shared pnpm runner and keep Control UI chunking helpers in sparse-included source so native Windows Corepack builds can produce <code>dist/control-ui</code>.</li>
|
||||
<li>Tests: give the memory fallback QA scenario enough turn budget to exercise native Windows gateway runs instead of failing on the client timeout while the mock agent is still dispatching.</li>
|
||||
<li>Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.</li>
|
||||
<li>Install/update: bypass npm <code>min-release-age</code> policies with <code>--min-release-age=0</code> instead of <code>--before</code> so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.</li>
|
||||
<li>Diagnostics: reclaim wedged session lanes when stale active-run bookkeeping blocks queued work despite no forward progress. Fixes #85639. Thanks @openperf.</li>
|
||||
<li>WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.</li>
|
||||
<li>Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.</li>
|
||||
<li>Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.</li>
|
||||
<li>Agents/tests: keep model catalog visibility on static selection helpers so catalog visibility checks avoid the broad model-selection barrel import.</li>
|
||||
<li>Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.</li>
|
||||
<li>xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.</li>
|
||||
<li>CLI: suppress benign self-update version-skew warnings during package post-update finalization.</li>
|
||||
<li>Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or <code>n/a</code> results.</li>
|
||||
<li>Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.</li>
|
||||
<li>Docker: restore writable <code>~/.config</code> in runtime images. Fixes #85968. Thanks @hkoessler and @Bartok9.</li>
|
||||
<li>Plugin SDK: keep legacy root diagnostic subscriptions connected when built plugin SDK aliases resolve diagnostic helpers through a separate module graph.</li>
|
||||
<li>Diagnostics: export alertable OTel and Prometheus signals for blocked tools, model failover, stale sessions, liveness warnings, oversized payloads, and webhook ingress while fixing shared OTLP endpoints with query strings.</li>
|
||||
<li>Tests: normalize macOS canonical temp paths in exec allowlists, fs-safe trash assertions, installed plugin matching, Telegram topic-name stores, and built ACPX MCP server expectations so native macOS proof runners cover the intended behavior.</li>
|
||||
<li>Codex/app-server: preserve message-tool-only source reply delivery mode on active runs so sub-agent completion wakeups can steer the active Codex turn instead of being rejected. (#86287) Thanks @ferminquant.</li>
|
||||
<li>Tests: sample the Windows kitchen-sink RPC gateway directly and serialize RSS probes so native runs keep the memory guard active.</li>
|
||||
<li>Tests: normalize bundled plugin lifecycle probe paths and state-root lookup so native Windows release sweeps accept valid packaged plugin installs.</li>
|
||||
<li>Agents/Claude CLI: route live native Bash permission requests through OpenClaw exec policy so Claude turns no longer stall on <code>control_request</code>, and document that OpenClaw exec policy is authoritative. Fixes #80819. (#86330, from #81971) Thanks @guthirry and @sallyom.</li>
|
||||
<li>Security audit: warn when YOLO OpenClaw exec policy overrides a restrictive raw Claude <code>--permission-mode</code> for managed live sessions. (#86557) Thanks @sallyom.</li>
|
||||
<li>Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.</li>
|
||||
<li>Codex: log when implicit app-server <code>never</code> approvals are promoted for OpenClaw tool policy, including whether the trigger was a <code>before_tool_call</code> hook or trusted tool policy.</li>
|
||||
<li>Codex harness: make subscription usage-limit errors without reset times explain that OpenClaw cannot determine the reset and point users to wait until Codex is available, use another Codex account, or switch to another configured model/provider. Thanks @amknight.</li>
|
||||
<li>Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.</li>
|
||||
<li>Telegram: route normal <code>[telegram][diag]</code> polling diagnostics through <code>runtime.log</code> while keeping non-diag warnings and persistence failures on <code>runtime.error</code>, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.</li>
|
||||
<li>Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.</li>
|
||||
<li>Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.</li>
|
||||
<li>iMessage: dedupe watcher startup when <code>channels.imessage.accounts</code> lists both <code>default</code> and a named account that point at the same local Messages source, so the gateway no longer spawns two <code>imsg rpc</code> processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and <code>openclaw doctor</code> flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawErrorState
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
@@ -473,6 +474,14 @@ private fun GatewaySetupScreen(
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
error?.let { message ->
|
||||
item {
|
||||
ClawErrorState(
|
||||
title = "Setup code issue",
|
||||
body = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
@@ -505,9 +514,6 @@ private fun GatewaySetupScreen(
|
||||
}
|
||||
ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Token optional")
|
||||
ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional")
|
||||
error?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,15 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -78,9 +82,16 @@ internal fun ProvidersModelsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
|
||||
@@ -13,11 +13,14 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -88,8 +91,15 @@ internal fun SessionsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -133,11 +143,16 @@ internal fun SessionsScreen(
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight(0.56f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
@@ -155,10 +170,6 @@ internal fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,15 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -1028,8 +1032,11 @@ internal fun SettingsDetailFrame(
|
||||
onBack: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
SettingsBackButton(onClick = onBack)
|
||||
@@ -1045,9 +1052,6 @@ internal fun SettingsDetailFrame(
|
||||
content()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NodeRuntime
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.ClawBottomNav
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawNavItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -24,20 +27,26 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -54,6 +63,7 @@ import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -69,23 +79,32 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private enum class Tab(
|
||||
internal enum class Tab(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Overview(key = "overview", label = "Home"),
|
||||
Chat(key = "chat", label = "Chat"),
|
||||
Voice(key = "voice", label = "Voice"),
|
||||
Sessions(key = "sessions", label = "Sessions"),
|
||||
Settings(key = "settings", label = "Settings"),
|
||||
ProvidersModels(key = "providers-models", label = "Providers"),
|
||||
Overview(key = "overview", label = "Home", icon = Icons.Default.Home),
|
||||
Chat(key = "chat", label = "Chat", icon = Icons.Outlined.ChatBubbleOutline),
|
||||
Voice(key = "voice", label = "Voice", icon = Icons.Outlined.MicNone),
|
||||
Sessions(key = "sessions", label = "Sessions", icon = Icons.Outlined.AccessTime),
|
||||
Settings(key = "settings", label = "Settings", icon = Icons.Outlined.Settings),
|
||||
ProvidersModels(key = "providers-models", label = "Providers", icon = Icons.Outlined.Inventory2),
|
||||
}
|
||||
|
||||
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Settings)
|
||||
|
||||
private val shellContentInsets: WindowInsets
|
||||
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
|
||||
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
|
||||
|
||||
/** Main post-onboarding shell that owns top-level Android navigation state. */
|
||||
@Composable
|
||||
fun ShellScreen(
|
||||
@@ -131,117 +150,144 @@ fun ShellScreen(
|
||||
commandOpen = false
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
val density = LocalDensity.current
|
||||
val keyboardVisible = WindowInsets.ime.getBottom(density) > 0
|
||||
val showBottomNav = shellBottomNavVisible(keyboardVisible = keyboardVisible, commandOpen = commandOpen)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = ClawTheme.colors.canvas,
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
if (showBottomNav) {
|
||||
ClawBottomNav(
|
||||
items = shellNavTabs.map { ClawNavItem(key = it.key, label = it.label, icon = it.icon) },
|
||||
selectedKey = if (activeTab in shellNavTabs) activeTab.key else Tab.Overview.key,
|
||||
onSelect = { key ->
|
||||
val next = shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview
|
||||
if (next == Tab.Settings) {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
activeTab = next
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { shellPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
onOpenSessions = { activeTab = Tab.Sessions },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
},
|
||||
onBackHome = { activeTab = Tab.Overview },
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,33 +335,39 @@ private fun OverviewScreen(
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val agents by viewModel.gatewayAgents.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val usageSummary by viewModel.usageSummary.collectAsState()
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
pendingApprovals = pendingToolCalls.size,
|
||||
channelsSummary = channelsSummary,
|
||||
nodesDevicesSummary = nodesDevicesSummary,
|
||||
readyProviderCount = readyProviderCount,
|
||||
)
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 20)
|
||||
viewModel.refreshModelCatalog()
|
||||
viewModel.refreshAgents()
|
||||
viewModel.refreshCronJobs()
|
||||
viewModel.refreshUsage()
|
||||
viewModel.refreshSkills()
|
||||
viewModel.refreshNodesDevices()
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 104.dp)) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -334,41 +386,20 @@ private fun OverviewScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = "MODULES")
|
||||
CompanionHeroPanel(
|
||||
statusText = gatewaySummary(statusText, isConnected),
|
||||
isConnected = isConnected,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onOpenChat = { onSelectTab(Tab.Chat) },
|
||||
onOpenVoice = { onSelectTab(Tab.Voice) },
|
||||
onOpenGateway = { onOpenSettingsRoute(SettingsRoute.Gateway) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Chat", null, null, Icons.Outlined.ChatBubbleOutline, Tab.Chat),
|
||||
ModuleRow("Sessions", null, if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow("Voice", null, if (isConnected) "Ready" else "Offline", Icons.Outlined.MicNone, Tab.Voice),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = null,
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", null, channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Agents", null, if (agents.isEmpty()) "Load" else "${agents.size} ready", Icons.Default.Person, Tab.Settings, SettingsRoute.Agents),
|
||||
ModuleRow("Approvals", null, approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Cron Jobs", null, cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, Tab.Settings, SettingsRoute.CronJobs),
|
||||
ModuleRow("Skills", null, skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, Tab.Settings, SettingsRoute.Skills),
|
||||
ModuleRow("Nodes & Devices", null, nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Usage", null, usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, Tab.Settings, SettingsRoute.Usage),
|
||||
ModuleRow("Settings", null, null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
if (attentionRows.isNotEmpty()) {
|
||||
item {
|
||||
HomeAttentionPanel(rows = attentionRows, onSelectTab = onSelectTab, onOpenSettingsRoute = onOpenSettingsRoute)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -397,7 +428,7 @@ private fun OverviewScreen(
|
||||
item {
|
||||
RecentSessionList(
|
||||
rows =
|
||||
sessions.take(7).map { session ->
|
||||
sessions.take(5).map { session ->
|
||||
RecentSessionListItem(
|
||||
key = session.key,
|
||||
title = displaySessionTitle(session.displayName),
|
||||
@@ -412,8 +443,39 @@ private fun OverviewScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = "Control center")
|
||||
}
|
||||
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = "Model setup",
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", "Connected messengers", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Nodes & Devices", "Phone and node health", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Approvals", "Tool decisions", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Settings", "More runtime controls", null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
}
|
||||
}
|
||||
OverviewChatButton(onClick = { onSelectTab(Tab.Chat) }, modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,26 +489,109 @@ private data class ModuleRow(
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
/** Floating overview shortcut that keeps chat one tap away from module lists. */
|
||||
@Composable
|
||||
private fun OverviewChatButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
private fun CompanionHeroPanel(
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenGateway: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.button),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(text = "Chat", style = ClawTheme.type.label.copy(fontSize = 16.sp, lineHeight = 20.sp))
|
||||
ClawPanel(contentPadding = PaddingValues(16.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(38.dp),
|
||||
shape = CircleShape,
|
||||
color = if (isConnected) ClawTheme.colors.successSoft else ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, if (isConnected) ClawTheme.colors.success else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(19.dp), tint = if (isConnected) ClawTheme.colors.success else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = if (pendingRunCount > 0) "OpenClaw is working" else "Ready when you are", style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 24.sp), color = ClawTheme.colors.text)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
ClawPrimaryButton(text = "Start chat", icon = Icons.Outlined.ChatBubbleOutline, onClick = onOpenChat, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Voice", icon = Icons.Outlined.MicNone, onClick = onOpenVoice, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (!isConnected) {
|
||||
ClawSecondaryButton(text = "Reconnect gateway", icon = Icons.Default.Cloud, onClick = onOpenGateway, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class HomeAttentionRow(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val tab: Tab,
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
internal fun homeAttentionRows(
|
||||
isConnected: Boolean,
|
||||
pendingApprovals: Int,
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
HomeAttentionRow("Gateway", "Connect before chat, voice, and live status.", Icons.Default.Cloud, Tab.Settings, SettingsRoute.Gateway)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (pendingApprovals > 0) {
|
||||
HomeAttentionRow("Approvals", approvalsSummary(pendingApprovals), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (channelsSummary.channels.any { it.error != null }) {
|
||||
HomeAttentionRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
|
||||
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun HomeAttentionPanel(
|
||||
rows: List<HomeAttentionRow>,
|
||||
onSelectTab: (Tab) -> Unit,
|
||||
onOpenSettingsRoute: (SettingsRoute) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "Needs attention", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.warning)
|
||||
rows.forEach { row ->
|
||||
ModuleListRow(
|
||||
row = ModuleRow(row.title, row.subtitle, null, row.icon, row.tab, row.settingsRoute),
|
||||
onClick = {
|
||||
val route = row.settingsRoute
|
||||
if (route == null) {
|
||||
onSelectTab(row.tab)
|
||||
} else {
|
||||
onOpenSettingsRoute(route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,14 +672,18 @@ private fun ModuleListRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.subtitle?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
row.metadata?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusDotColor(it)))
|
||||
@@ -638,11 +787,18 @@ private fun RecentSessionRowContent(
|
||||
@Composable
|
||||
private fun ChatShellScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
ChatScreen(viewModel = viewModel, onBack = onBack, onVoice = onVoice)
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
ChatScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = onVoice,
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +809,10 @@ private fun VoiceShellScreen(
|
||||
onOpenGatewaySettings: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
VoiceScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = onOpenCommand,
|
||||
@@ -669,6 +828,7 @@ private fun SettingsShellScreen(
|
||||
route: SettingsRoute,
|
||||
onRouteChange: (SettingsRoute) -> Unit,
|
||||
onRouteBack: () -> Unit,
|
||||
onBackHome: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
@@ -707,14 +867,18 @@ private fun SettingsShellScreen(
|
||||
return
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(13.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
PlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to home", onClick = onBackHome)
|
||||
Text(text = "Settings", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SettingsSearchButton(onClick = onOpenCommand)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawLoadingState
|
||||
@@ -37,11 +38,11 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -78,8 +79,8 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
|
||||
@@ -158,13 +159,23 @@ fun ChatScreen(
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onBack = onBack,
|
||||
onMore = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
)
|
||||
|
||||
ChatSessionSwitcher(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
onSelectSession = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
|
||||
}
|
||||
@@ -214,13 +225,88 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionSwitcher(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val choices =
|
||||
remember(sessionKey, sessions, mainSessionKey) {
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
if (choices.size <= 1 && sessions.size <= 1) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
choices.forEach { entry ->
|
||||
ChatSessionChip(
|
||||
text = chatSessionChipText(entry = entry, mainSessionKey = mainSessionKey),
|
||||
active = isActiveSessionChoice(entry.key, sessionKey, mainSessionKey),
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
)
|
||||
}
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(text = "All", style = ClawTheme.type.caption, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionChip(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -228,7 +314,7 @@ private fun ChatHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -786,13 +872,33 @@ private fun AttachmentChip(
|
||||
|
||||
private fun currentSessionTitle(
|
||||
sessionKey: String,
|
||||
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): String {
|
||||
val entry = sessions.firstOrNull { it.key == sessionKey }
|
||||
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun chatSessionChipText(
|
||||
entry: ChatSessionEntry,
|
||||
mainSessionKey: String,
|
||||
): String {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (entry.key == mainKey || (entry.key == "main" && mainKey == "main")) return "Main"
|
||||
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: "Current"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun isActiveSessionChoice(
|
||||
choiceKey: String,
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): Boolean {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = sessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
|
||||
@@ -4,22 +4,9 @@ import ai.openclaw.app.chat.ChatSessionEntry
|
||||
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Derive a human-friendly label from a raw session key.
|
||||
* Examples:
|
||||
* "telegram:g-agent-main-main" -> "Main"
|
||||
* "agent:main:main" -> "Main"
|
||||
* "discord:g-server-channel" -> "Server Channel"
|
||||
* "my-custom-session" -> "My Custom Session"
|
||||
*/
|
||||
fun friendlySessionName(key: String): String {
|
||||
// Strip common prefixes like "telegram:", "agent:", "discord:" etc.
|
||||
val stripped = key.substringAfterLast(":")
|
||||
|
||||
// Remove leading "g-" prefix (gateway artifact)
|
||||
val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped
|
||||
|
||||
// Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main"
|
||||
val words =
|
||||
cleaned
|
||||
.split('-', '_')
|
||||
@@ -78,3 +65,29 @@ fun resolveSessionChoices(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun resolveCompactSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
maxOptions: Int = 5,
|
||||
): List<ChatSessionEntry> {
|
||||
val allChoices =
|
||||
resolveSessionChoices(
|
||||
currentSessionKey = currentSessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
nowMs = nowMs,
|
||||
)
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index }
|
||||
val unpinnedRank = pinnedRank.size
|
||||
|
||||
return allChoices
|
||||
.withIndex()
|
||||
.sortedWith(compareBy({ pinnedRank[it.value.key] ?: unpinnedRank }, { it.index }))
|
||||
.take(maxOptions)
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ internal enum class ClawStatus {
|
||||
internal fun ClawScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
|
||||
contentWindowInsets: WindowInsets = WindowInsets.safeDrawing,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -68,7 +69,7 @@ internal fun ClawScaffold(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.windowInsetsPadding(contentWindowInsets)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -91,27 +92,29 @@ internal fun ClawBottomNav(
|
||||
) {
|
||||
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +132,7 @@ private fun ClawBottomNavItem(
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ShellScreenLogicTest {
|
||||
@Test
|
||||
fun bottomNavHidesForKeyboardAndCommandPalette() {
|
||||
assertTrue(shellBottomNavVisible(keyboardVisible = false, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = true, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = false,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gateway"), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceOnlyActionableConnectedIssues() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 2,
|
||||
channelsSummary =
|
||||
GatewayChannelsSummary(
|
||||
channels =
|
||||
listOf(
|
||||
GatewayChannelSummary(
|
||||
id = "telegram",
|
||||
label = "Telegram",
|
||||
accountCount = 1,
|
||||
enabled = true,
|
||||
configured = true,
|
||||
linked = true,
|
||||
running = false,
|
||||
connected = false,
|
||||
error = "offline",
|
||||
),
|
||||
),
|
||||
),
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
pendingDevices =
|
||||
listOf(
|
||||
GatewayPendingDeviceSummary(
|
||||
requestId = "request-1",
|
||||
deviceId = "device-1",
|
||||
displayName = "Phone",
|
||||
remoteIp = null,
|
||||
roles = emptyList(),
|
||||
scopes = emptyList(),
|
||||
requestedAtMs = null,
|
||||
repair = false,
|
||||
),
|
||||
),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsStayQuietWhenConnectedAndHealthy() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
}
|
||||
@@ -32,4 +32,29 @@ class SessionFiltersTest {
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactChoicesKeepMainAndCurrentWhileCappingRecentSessions() {
|
||||
val now = 1_700_000_000_000L
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "recent-1", updatedAtMs = now - 1),
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = now - 2),
|
||||
ChatSessionEntry(key = "recent-3", updatedAtMs = now - 3),
|
||||
ChatSessionEntry(key = "recent-4", updatedAtMs = now - 4),
|
||||
ChatSessionEntry(key = "main", updatedAtMs = now - 5),
|
||||
ChatSessionEntry(key = "active-old", updatedAtMs = now - 30 * 60 * 60 * 1000L),
|
||||
)
|
||||
|
||||
val result =
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = "active-old",
|
||||
sessions = sessions,
|
||||
mainSessionKey = "main",
|
||||
nowMs = now,
|
||||
maxOptions = 4,
|
||||
).map { it.key }
|
||||
|
||||
assertEquals(listOf("main", "active-old", "recent-1", "recent-2"), result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMetadataParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
cc0fb4e3f1a7e8f233626adb80d686608ddac8c177fe6a55b33970c2baf4ace4 config-baseline.json
|
||||
042ca98e6200a365accda00e5a6f3e72bdae5853f39ff0cdc3b2cb9c0d6f8f3e config-baseline.core.json
|
||||
cbf81829dcc8cfd0a16435912da709f8c1d508707385b6493f94cafe211ec67c config-baseline.channel.json
|
||||
4012b1f8de6f9527c47320a6c7120f30dc30ac1b5524ed63dadef890aad44b20 config-baseline.plugin.json
|
||||
e3b8988a10c61dbf0a78a70bca9ef1ab43c6a58aeaa5ef9f8699f34b6dae4c9d config-baseline.json
|
||||
a2f53abfe6bbe8b1ddfa5548f555704d8ff0cdd48bcb5780d66499bec0b7775a config-baseline.core.json
|
||||
3d0f7723873da553f25dfe6892a586d774fa36e447de487eba4dd3e0a012f877 config-baseline.channel.json
|
||||
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
|
||||
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
|
||||
9ce72d763de6c95566e0167f99f5454b07c7c67940675533cb24c07058619a63 plugin-sdk-api-baseline.json
|
||||
e4dfccb85b985fe865145e24978255b729cdcbca0e26650a363a11bfcfc2e27b plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -319,6 +319,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
|
||||
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
|
||||
|
||||
@@ -218,17 +218,6 @@ Target-side auth-required installs are reported on the affected plugin item with
|
||||
Their explicit config entries are written disabled until you reauthorize and
|
||||
enable them. Other install failures are item-scoped `error` results.
|
||||
|
||||
The native Codex plugin config also accepts first-party `openai-bundled` and
|
||||
`openai-primary-runtime` marketplace identities, but migration does not
|
||||
auto-discover or install them from source state.
|
||||
|
||||
OpenAI-side app/plugin availability still comes from the signed-in Codex
|
||||
account and workspace app controls. See
|
||||
[Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan)
|
||||
for OpenAI's account and workspace-control overview, then use
|
||||
[Native Codex plugins](/plugins/codex-native-plugins#manual-first-party-marketplace-entries)
|
||||
for manual first-party marketplace entries.
|
||||
|
||||
If Codex app-server plugin inventory is unavailable during planning, migration
|
||||
falls back to cached bundle advisory items instead of failing the whole
|
||||
migration.
|
||||
|
||||
@@ -157,13 +157,11 @@ is available, then fall back to `latest`.
|
||||
`--pin` applies to npm installs only. It is not supported with `git:` installs; use an explicit git ref such as `git:github.com/acme/plugin@v1.2.3` when you want a pinned source. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec.
|
||||
</Accordion>
|
||||
<Accordion title="--dangerously-force-unsafe-install">
|
||||
`--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** bypass plugin `before_install` hook policy blocks and does **not** bypass scan failures.
|
||||
`--dangerously-force-unsafe-install` is deprecated and is now a no-op. OpenClaw no longer runs built-in install-time dangerous-code blocking for plugin installs.
|
||||
|
||||
Install scans ignore common test files and directories such as `tests/`, `__tests__/`, `*.test.*`, and `*.spec.*` to avoid blocking packaged test mocks; declared plugin runtime entrypoints are still scanned even if they use one of those names.
|
||||
Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks and `security.installPolicy` can still block installs.
|
||||
|
||||
This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow.
|
||||
|
||||
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` only affects installs on your own machine; it does not ask ClawHub to rescan the plugin or make a blocked release public.
|
||||
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
@@ -185,7 +183,7 @@ is available, then fall back to `latest`.
|
||||
<Accordion title="Git repositories">
|
||||
Use `git:<repo>` to install directly from a git repository. Supported forms include `git:github.com/owner/repo`, `git:owner/repo`, full `https://`, `ssh://`, `git://`, `file://`, and `git@host:owner/repo.git` clone URLs. Add `@<ref>` or `#<ref>` to check out a branch, tag, or commit before install.
|
||||
|
||||
Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, dangerous-code scanning, package-manager install work, and install records behave like npm installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later.
|
||||
Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, operator install policy, package-manager install work, and install records behave like npm installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later.
|
||||
|
||||
After installing from git, use `openclaw plugins inspect <id> --runtime --json` to verify runtime registrations such as gateway methods and CLI commands. If the plugin registered a CLI root with `api.registerCli`, execute that command directly through the OpenClaw root CLI, for example `openclaw demo-plugin ping`.
|
||||
|
||||
@@ -267,6 +265,10 @@ For local paths and archives, OpenClaw auto-detects:
|
||||
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude component layout)
|
||||
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
|
||||
|
||||
Managed local installs must be plugin directories or archives. Standalone `.js`,
|
||||
`.mjs`, `.cjs`, and `.ts` plugin files are not copied into the managed plugin
|
||||
root by `plugins install`; list them explicitly in `plugins.load.paths` instead.
|
||||
|
||||
<Note>
|
||||
Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` defaults, Cursor command-skills, and compatible Codex hook directories are supported; other detected bundle capabilities are shown in diagnostics/info but are not yet wired into runtime execution.
|
||||
</Note>
|
||||
@@ -320,13 +322,17 @@ For runtime hook debugging:
|
||||
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway URL/profile, service/process hints, config path, and RPC health.
|
||||
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
Use `--link` to avoid copying a local plugin directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
|
||||
Standalone plugin files must be listed in `plugins.load.paths` rather than
|
||||
installed with `plugins install` or placed directly in `~/.openclaw/extensions`
|
||||
or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin
|
||||
package or bundle directories, while top-level script files are treated as local
|
||||
helpers and skipped.
|
||||
|
||||
<Note>
|
||||
Workspace-origin plugins discovered from a workspace extensions root are not
|
||||
@@ -399,7 +405,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="--dangerously-force-unsafe-install on update">
|
||||
`--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates.
|
||||
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` and plugin `before_install` hooks can still block updates.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ instead of creating a separate health gate.
|
||||
|
||||
Policy currently manages configured channels, MCP servers, model providers,
|
||||
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
|
||||
OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
declarations. For example, IT or a workspace operator can record that Telegram
|
||||
is not an approved channel provider, restrict MCP servers and model refs to
|
||||
approved entries, require private-network fetch/browser access to remain
|
||||
@@ -28,7 +28,9 @@ to stay within reviewed bounds, require Gateway bind/auth/HTTP exposure to stay
|
||||
bounds, require agent workspace access and tool denies to stay in a reviewed
|
||||
posture, require OpenClaw config SecretRefs to use managed providers, require
|
||||
config auth profiles to carry provider/mode metadata, require governed tools to
|
||||
carry risk and sensitivity metadata, then use `doctor --lint` as the shared
|
||||
carry risk and sensitivity metadata, require sensitive logging redaction, deny
|
||||
telemetry content capture, require session retention maintenance, deny session
|
||||
transcript memory indexing, then use `doctor --lint` as the shared
|
||||
conformance gate.
|
||||
|
||||
Use policy when a workspace needs a durable statement such as "these channels
|
||||
@@ -52,7 +54,7 @@ doctor can report the missing artifact.
|
||||
Policy is authored, not generated from the user's current settings. A minimal
|
||||
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
|
||||
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
|
||||
config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -118,6 +120,20 @@ config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
"denyTools": ["exec", "process", "write", "edit", "apply_patch"],
|
||||
},
|
||||
},
|
||||
"dataHandling": {
|
||||
"sensitiveLogging": {
|
||||
"requireRedaction": true,
|
||||
},
|
||||
"telemetry": {
|
||||
"denyContentCapture": true,
|
||||
},
|
||||
"retention": {
|
||||
"requireSessionMaintenance": true,
|
||||
},
|
||||
"memory": {
|
||||
"denySessionTranscriptIndexing": true,
|
||||
},
|
||||
},
|
||||
"secrets": {
|
||||
"requireManagedProviders": true,
|
||||
"denySources": ["exec"],
|
||||
@@ -155,7 +171,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
|
||||
`mcp.servers.*`, `models.providers.*`, selected agent model refs, network SSRF
|
||||
settings, direct-message session scope, channel DM policy, channel group policy,
|
||||
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
|
||||
posture, OpenClaw config agent sandbox workspace access and tool deny posture, config secret
|
||||
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
|
||||
data-handling config posture, config secret
|
||||
provider and SecretRef provenance, config auth profile metadata, configured
|
||||
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
|
||||
reports observed state that does not conform. If a policy denies non-loopback
|
||||
@@ -176,6 +193,11 @@ runtime. Secret evidence records
|
||||
provider/source posture and SecretRef metadata, never raw secret values. Policy
|
||||
does not read or attest per-agent credential stores such as `auth-profiles.json`;
|
||||
those stores remain owned by the existing auth and credential flows.
|
||||
Data-handling evidence is config-level posture only: it checks configured
|
||||
redaction mode, telemetry content-capture toggles, session maintenance mode, and
|
||||
session-transcript memory indexing settings. It does not inspect raw logs,
|
||||
telemetry exports, transcript contents, memory files, or prove that no personal
|
||||
data or secrets exist.
|
||||
|
||||
### Policy rule reference
|
||||
|
||||
@@ -183,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is
|
||||
present in `policy.jsonc`. The observed state is existing OpenClaw config or
|
||||
workspace metadata; policy reports drift but does not rewrite runtime behavior
|
||||
unless a repair path is explicitly available and enabled.
|
||||
Policy files are strict: unsupported sections or rule keys are reported as
|
||||
`policy/policy-jsonc-invalid` instead of being ignored.
|
||||
|
||||
Policy overlays keep broad top-level rules global, then let named scope blocks
|
||||
add stricter normal policy sections for explicit selectors. A scope name is a
|
||||
@@ -194,7 +218,8 @@ its own finding against the same observed config.
|
||||
|
||||
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
|
||||
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
|
||||
supports `tools.*`, `agents.workspace.*`, and `sandbox.*`. Channel-scoped
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
|
||||
`dataHandling.memory.*`. Channel-scoped
|
||||
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
|
||||
sections are rejected instead of being ignored. If an `agentIds` entry is not
|
||||
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
|
||||
@@ -233,6 +258,11 @@ global/default posture for that runtime agent id.
|
||||
"requireMode": ["all"],
|
||||
"allowBackends": ["docker"],
|
||||
},
|
||||
"dataHandling": {
|
||||
"memory": {
|
||||
"denySessionTranscriptIndexing": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"shell-sandbox": {
|
||||
"agentIds": ["shell-agent"],
|
||||
@@ -274,10 +304,10 @@ groups where those fields cannot be observed.
|
||||
Top-level `ingress.session.requireDmScope` remains global because
|
||||
`session.dmScope` is not channel-attributable evidence.
|
||||
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ------------------------------------------ | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, and `sandbox` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
|
||||
Every scope present in `policy.jsonc` must be valid and enforceable.
|
||||
|
||||
@@ -354,6 +384,15 @@ Policy treats missing `sandbox.mode` as the implicit default `off`, so
|
||||
`sandbox.requireMode` reports a fresh or unconfigured sandbox as outside an
|
||||
allowlist such as `["all"]`.
|
||||
|
||||
#### Data Handling
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
|
||||
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
|
||||
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
|
||||
| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
|
||||
|
||||
#### Secrets
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
@@ -674,63 +713,67 @@ choose a different interval.
|
||||
|
||||
Policy currently verifies:
|
||||
|
||||
| Check id | Finding |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
|
||||
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
|
||||
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
|
||||
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
|
||||
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
|
||||
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
|
||||
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
|
||||
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
|
||||
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
|
||||
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
|
||||
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
|
||||
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
|
||||
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
|
||||
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
|
||||
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
|
||||
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
|
||||
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
|
||||
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
|
||||
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
|
||||
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
|
||||
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
|
||||
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
|
||||
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
|
||||
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
|
||||
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
|
||||
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
|
||||
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
|
||||
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
|
||||
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
|
||||
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
|
||||
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
|
||||
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
|
||||
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
|
||||
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
|
||||
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
|
||||
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
|
||||
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
|
||||
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
|
||||
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
|
||||
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
|
||||
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
|
||||
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
|
||||
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
|
||||
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
|
||||
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
|
||||
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
|
||||
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
|
||||
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
|
||||
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
|
||||
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
|
||||
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
|
||||
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
|
||||
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
|
||||
| Check id | Finding |
|
||||
| -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
|
||||
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
|
||||
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
|
||||
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
|
||||
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
|
||||
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
|
||||
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
|
||||
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
|
||||
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
|
||||
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
|
||||
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
|
||||
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
|
||||
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
|
||||
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
|
||||
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
|
||||
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
|
||||
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
|
||||
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
|
||||
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
|
||||
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
|
||||
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
|
||||
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
|
||||
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
|
||||
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
|
||||
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
|
||||
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
|
||||
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
|
||||
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
|
||||
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
|
||||
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
|
||||
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
|
||||
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
|
||||
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
|
||||
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
|
||||
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
|
||||
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
|
||||
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
|
||||
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
|
||||
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
|
||||
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
|
||||
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
|
||||
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
|
||||
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
|
||||
| `policy/data-handling-redaction-disabled` | Sensitive logging redaction is disabled when policy requires it. |
|
||||
| `policy/data-handling-telemetry-content-capture` | Telemetry content capture is enabled when policy denies it. |
|
||||
| `policy/data-handling-session-retention-not-enforced` | Session retention maintenance is not enforced when policy requires it. |
|
||||
| `policy/data-handling-session-transcript-memory-enabled` | Session transcript memory indexing is enabled when policy denies it. |
|
||||
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
|
||||
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
|
||||
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
|
||||
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
|
||||
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
|
||||
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
|
||||
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
|
||||
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
|
||||
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
|
||||
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
|
||||
|
||||
Policy findings can include both `target` and `requirement`. `target` is the
|
||||
observed workspace thing that does not conform. `requirement` is the authored
|
||||
|
||||
@@ -97,7 +97,7 @@ These run inside the agent loop or gateway pipeline:
|
||||
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
||||
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
||||
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
||||
- **`before_install`**: inspect built-in scan findings and optionally block skill or plugin installs.
|
||||
- **`before_install`**: inspect install context and optionally block skill or plugin installs after operator install policy runs.
|
||||
- **`tool_result_persist`**: synchronously transform tool results before they are written to an OpenClaw-owned session transcript.
|
||||
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||
|
||||
@@ -194,10 +194,12 @@ OpenClaw resolves that behavior by conversation type:
|
||||
`message(action=send)`.
|
||||
- Internal orchestration allows silence by default.
|
||||
|
||||
OpenClaw also uses silent replies for internal runner failures that happen
|
||||
before any assistant reply in non-direct chats, so groups/channels do not see
|
||||
gateway error boilerplate. Direct chats show compact failure copy by default;
|
||||
raw runner details are shown only when `/verbose full` is enabled.
|
||||
OpenClaw also uses silent replies for generic internal runner failures in
|
||||
non-direct chats, so groups/channels do not see gateway error boilerplate.
|
||||
Classified failures with user-facing recovery copy, such as missing auth,
|
||||
rate-limit, or overload notices, can still be delivered. Direct chats show
|
||||
compact failure copy by default; raw runner details are shown only when
|
||||
`/verbose full` is enabled.
|
||||
|
||||
Defaults live under `agents.defaults.silentReply`; `surfaces.<id>.silentReply`
|
||||
can override group/internal policy per surface.
|
||||
|
||||
@@ -110,8 +110,8 @@ writes.
|
||||
## Session maintenance
|
||||
|
||||
OpenClaw automatically bounds session storage over time. By default, it runs
|
||||
in `warn` mode (reports what would be cleaned). Set `session.maintenance.mode`
|
||||
to `"enforce"` for automatic cleanup:
|
||||
in `enforce` mode and applies cleanup during maintenance. Set
|
||||
`session.maintenance.mode` to `"warn"` to report what would be cleaned without mutating the store/files:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -1272,7 +1272,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
|
||||
maintenance: {
|
||||
mode: "warn", // warn | enforce
|
||||
mode: "enforce", // enforce (default) | warn
|
||||
pruneAfter: "30d",
|
||||
maxEntries: 500,
|
||||
resetArchiveRetention: "30d", // duration or false
|
||||
@@ -1311,7 +1311,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`-`20`, default: `5`). `0` disables ping-pong chaining.
|
||||
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
|
||||
- **`maintenance`**: session-store cleanup + retention controls.
|
||||
- `mode`: `warn` emits warnings only; `enforce` applies cleanup.
|
||||
- `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only.
|
||||
- `pruneAfter`: age cutoff for stale entries (default `30d`).
|
||||
- `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately.
|
||||
- `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs.
|
||||
|
||||
@@ -316,10 +316,7 @@ conversation bindings, or any non-Codex harness.
|
||||
migrated plugin entry when global `codexPlugins.enabled` is also true.
|
||||
Default: `true` for explicit entries.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.marketplaceName`:
|
||||
stable marketplace identity. V1 supports `"openai-curated"`,
|
||||
`"openai-bundled"`, and `"openai-primary-runtime"`. See
|
||||
[Native Codex plugins](/plugins/codex-native-plugins#manual-first-party-marketplace-entries)
|
||||
for manual bundled and primary-runtime examples.
|
||||
stable marketplace identity. V1 only supports `"openai-curated"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.pluginName`: stable
|
||||
Codex plugin identity from migration, for example `"google-calendar"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
|
||||
|
||||
@@ -612,8 +612,11 @@ terminal summary, and sanitized error text.
|
||||
`skills.upload.begin` request. This mode is rejected unless
|
||||
`skills.install.allowUploadedArchives` is enabled. The setting does not
|
||||
affect ClawHub installs.
|
||||
- Gateway installer mode: `{ name, installId, dangerouslyForceUnsafeInstall?, timeoutMs? }`
|
||||
- Gateway installer mode: `{ name, installId, timeoutMs? }`
|
||||
runs a declared `metadata.openclaw.install` action on the gateway host.
|
||||
Older clients may still send `dangerouslyForceUnsafeInstall`; this field is
|
||||
deprecated, accepted only for protocol compatibility, and ignored. Use
|
||||
`security.installPolicy` for operator-owned install decisions.
|
||||
- Operators may call `skills.update` (`operator.admin`) in two modes:
|
||||
- ClawHub mode updates one tracked slug or all tracked ClawHub installs in
|
||||
the default agent workspace.
|
||||
|
||||
@@ -538,11 +538,11 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
|
||||
- Restart the Gateway after plugin changes.
|
||||
- If you install or update plugins (`openclaw plugins install <package>`, `openclaw plugins update <id>`), treat it like running untrusted code:
|
||||
- The install path is the per-plugin directory under the active plugin install root.
|
||||
- OpenClaw runs a built-in dangerous-code scan before install/update. `critical` findings block by default.
|
||||
- OpenClaw does not run built-in local dangerous-code blocking during install/update. Use `security.installPolicy` for operator-owned local allow/block decisions and `openclaw security audit --deep` for diagnostic scanning.
|
||||
- npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained plugin packages; OpenClaw copies/references them without running `npm install`.
|
||||
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
|
||||
- `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives on plugin install/update flows. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures.
|
||||
- Gateway-backed skill dependency installs follow the same dangerous/suspicious split: built-in `critical` findings block unless the caller explicitly sets `dangerouslyForceUnsafeInstall`, while suspicious findings still warn only. `openclaw skills install` remains the separate ClawHub skill download/install flow.
|
||||
- `--dangerously-force-unsafe-install` is deprecated and no longer changes plugin install/update behavior.
|
||||
- Configure `security.installPolicy` when operators need a trusted local command to make host-specific allow/block decisions for skill and plugin installs. This policy runs after source material is staged but before installation continues, applies to ClawHub skills too, and is not bypassed by deprecated unsafe flags.
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
|
||||
@@ -387,7 +387,8 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
- Add that directory to your user PATH (no `\bin` suffix needed on Windows; on most systems it is `%AppData%\npm`).
|
||||
- Close and reopen PowerShell after updating PATH.
|
||||
|
||||
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
|
||||
For desktop setup, use the native **Windows Hub** app. For terminal-only
|
||||
setup, the PowerShell installer and WSL2 Gateway paths are both supported.
|
||||
Docs: [Windows](/platforms/windows).
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1908,9 +1908,10 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
|
||||
<Accordion title="Are ClawHub skills and third-party plugins safe to install?">
|
||||
Treat third-party skills and plugins as code you are choosing to trust.
|
||||
ClawHub skill pages expose scan state before install, and OpenClaw plugin
|
||||
install/update flows run built-in dangerous-code checks, but scans are not a
|
||||
complete security boundary.
|
||||
ClawHub skill pages expose scan state before install, but scans are not a
|
||||
complete security boundary. OpenClaw does not run built-in local
|
||||
dangerous-code blocking during plugin or skill install/update flows; use
|
||||
operator-owned `security.installPolicy` for local allow/block decisions.
|
||||
|
||||
Safer pattern:
|
||||
|
||||
|
||||
@@ -105,6 +105,57 @@ Example:
|
||||
|
||||
Reference: [Plugin architecture](/plugins/architecture)
|
||||
|
||||
## Install policy blocks plugin installs or updates
|
||||
|
||||
If an update finishes but plugins are stale, disabled, or show messages such as
|
||||
`blocked by install policy`, `install policy failed closed`, or
|
||||
`Disabled "<plugin>" after plugin update failure`, check
|
||||
`security.installPolicy`.
|
||||
|
||||
Install policy runs on plugin installs and updates. OpenClaw-owned plugin
|
||||
versions normally move with the OpenClaw release, so an OpenClaw update can
|
||||
also need matching `@openclaw/*` plugin updates during post-update sync.
|
||||
|
||||
Avoid these broad policy shapes unless you also maintain the matching upgrade
|
||||
rule:
|
||||
|
||||
- Freezing OpenClaw-owned plugins to one exact old version, such as allowing
|
||||
only `@openclaw/*@2026.5.3`.
|
||||
- Blocking by source kind alone, such as every npm, network, or
|
||||
`request.mode: "update"` plugin request.
|
||||
- Treating the policy command as optional. When `security.installPolicy` is
|
||||
enabled, a missing, slow, unreadable, or permission-blocked policy executable
|
||||
fails closed.
|
||||
- Approving plugin versions without considering the policy request's
|
||||
`openclawVersion` and the plugin candidate metadata.
|
||||
|
||||
Safer policy rules allow trusted OpenClaw-owned plugin updates when the
|
||||
candidate is compatible with the current OpenClaw host, instead of pinning a
|
||||
single release forever. If you block npm by default, make a narrow exception
|
||||
for the trusted `@openclaw/*` plugin packages or plugin ids you use. If you
|
||||
differentiate install and update requests, apply the same trust rule to
|
||||
`request.mode: "update"`.
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
openclaw doctor --deep
|
||||
openclaw plugins update --all
|
||||
openclaw status --all
|
||||
```
|
||||
|
||||
If the policy is intentionally strict, relax it for the trusted OpenClaw upgrade
|
||||
window, rerun `openclaw plugins update --all`, then restore the stricter rule.
|
||||
If a plugin was disabled after update failure, inspect it and re-enable it only
|
||||
after the update succeeds:
|
||||
|
||||
```bash
|
||||
openclaw plugins inspect <plugin-id> --runtime --json
|
||||
openclaw plugins enable <plugin-id>
|
||||
```
|
||||
|
||||
Reference: [Operator install policy](/tools/skills-config#operator-install-policy-securityinstallpolicy)
|
||||
|
||||
## Plugin present but blocked by suspicious ownership
|
||||
|
||||
If `openclaw doctor`, setup, or startup warnings show:
|
||||
|
||||
@@ -10,13 +10,17 @@ title: "Install"
|
||||
## System requirements
|
||||
|
||||
- **Node 24** (recommended) or Node 22.19+ - the installer script handles this automatically
|
||||
- **macOS, Linux, or Windows** - both native Windows and WSL2 are supported; WSL2 is more stable. See [Windows](/platforms/windows).
|
||||
- **macOS, Linux, or Windows** - Windows users can start with the native Windows Hub app, the PowerShell CLI installer, or a WSL2 Gateway. See [Windows](/platforms/windows).
|
||||
- `pnpm` is only needed if you build from source
|
||||
|
||||
## Recommended: installer script
|
||||
|
||||
The fastest way to install. It detects your OS, installs Node if needed, installs OpenClaw, and launches onboarding.
|
||||
|
||||
<Note>
|
||||
Windows desktop users can also install the native [Windows Hub](/platforms/windows#recommended-windows-hub) companion app, which includes setup, tray status, chat, node mode, and local MCP mode.
|
||||
</Note>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="macOS / Linux / WSL2">
|
||||
```bash
|
||||
|
||||
@@ -18,7 +18,9 @@ The macOS app surfaces OpenClaw skills via the gateway; it does not parse skills
|
||||
|
||||
- `metadata.openclaw.install` defines install options (brew/node/go/uv).
|
||||
- The app calls `skills.install` to run installers on the gateway host.
|
||||
- Built-in dangerous-code `critical` findings block `skills.install` by default; suspicious findings still warn only. The dangerous override exists on the gateway request, but the default app flow stays fail-closed.
|
||||
- Operator-owned `security.installPolicy` can block gateway-backed skill
|
||||
installs before installer metadata runs. Install-time built-in dangerous-code
|
||||
blocking is not part of the skill install flow.
|
||||
- If every install option is `download`, the gateway surfaces all download
|
||||
choices.
|
||||
- Otherwise, the gateway picks one preferred installer using the current
|
||||
|
||||
@@ -1,119 +1,193 @@
|
||||
---
|
||||
summary: "Windows support: native and WSL2 install paths, daemon, and current caveats"
|
||||
summary: "Windows support: Windows Hub, native CLI and Gateway, WSL2 gateway setup, node mode, and troubleshooting"
|
||||
read_when:
|
||||
- Installing OpenClaw on Windows
|
||||
- Choosing between native Windows and WSL2
|
||||
- Looking for Windows companion app status
|
||||
- Choosing between Windows Hub, native Windows, and WSL2
|
||||
- Setting up the Windows companion app or Windows node mode
|
||||
title: "Windows"
|
||||
---
|
||||
|
||||
OpenClaw supports both **native Windows** and **WSL2**. WSL2 is the more
|
||||
stable path and recommended for the full experience — the CLI, Gateway, and
|
||||
tooling run inside Linux with full compatibility. Native Windows works for
|
||||
core CLI and Gateway use, with some caveats noted below.
|
||||
OpenClaw ships a native **Windows Hub** companion app plus Windows CLI support.
|
||||
Use Windows Hub when you want a desktop app with setup, tray status, chat,
|
||||
Command Center diagnostics, and Windows node capabilities. Use the PowerShell
|
||||
installer when you want the CLI/Gateway directly. Use WSL2 when you want the
|
||||
most Linux-compatible Gateway runtime.
|
||||
|
||||
Native Windows companion apps are planned.
|
||||
## Recommended: Windows Hub
|
||||
|
||||
## WSL2 (recommended)
|
||||
Windows Hub is the native WinUI companion app for Windows 10 20H2+ and Windows 11. It installs without administrator privileges and is published with signed
|
||||
x64 and ARM64 installers on OpenClaw releases.
|
||||
|
||||
- [Getting Started](/start/getting-started) (use inside WSL)
|
||||
- [Install & updates](/install/updating)
|
||||
- Official WSL2 guide (Microsoft): [https://learn.microsoft.com/windows/wsl/install](https://learn.microsoft.com/windows/wsl/install)
|
||||
Download the latest stable installer:
|
||||
|
||||
## Native Windows status
|
||||
- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-x64.exe)
|
||||
- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-arm64.exe)
|
||||
- [Checksums](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-SHA256SUMS.txt)
|
||||
|
||||
Native Windows CLI flows are improving, but WSL2 is still the recommended path.
|
||||
After install, launch **OpenClaw Companion** from the Start menu or the system
|
||||
tray. The installer also adds shortcuts for Gateway Setup, Chat, Settings,
|
||||
Check for Updates, and uninstall.
|
||||
|
||||
What works well on native Windows today:
|
||||
### What Windows Hub includes
|
||||
|
||||
- website installer via `install.ps1`
|
||||
- local CLI use such as `openclaw --version`, `openclaw doctor`, and `openclaw plugins list --json`
|
||||
- embedded local-agent/provider smoke such as:
|
||||
- system tray status and launch-at-login
|
||||
- first-run setup for a local app-owned WSL Gateway
|
||||
- connection settings for local, remote, and SSH-tunneled Gateways
|
||||
- native chat window plus access to the browser Control UI
|
||||
- Command Center diagnostics for sessions, usage, channels, nodes, pairing, and
|
||||
repair commands
|
||||
- Windows node mode for agent-controlled canvas, screen, camera, notifications,
|
||||
device status, text-to-speech, speech-to-text, and controlled `system.run`
|
||||
- local MCP server mode for MCP clients such as Claude Desktop, Claude Code, and
|
||||
Cursor
|
||||
|
||||
### First launch
|
||||
|
||||
On first launch, Windows Hub opens setup when there is no usable saved Gateway.
|
||||
The fastest path is **Set up locally**, which provisions an app-owned
|
||||
`OpenClawGateway` WSL distro, installs the Gateway inside it, and pairs the app.
|
||||
This does not export or mutate your existing Ubuntu distro.
|
||||
|
||||
Choose **Advanced setup** or open the Connections tab when you already have a
|
||||
Gateway. You can connect to:
|
||||
|
||||
- a local Gateway on this PC
|
||||
- a WSL Gateway on this PC
|
||||
- a remote Gateway by URL and token or setup code
|
||||
- a Gateway reached through an SSH tunnel
|
||||
|
||||
When setup finishes, the tray icon turns green. Open **Command Center** from the
|
||||
tray to confirm connection, pairing, node status, and channel health.
|
||||
|
||||
## Windows node mode
|
||||
|
||||
Windows Hub can register as a first-class OpenClaw node. The agent can then use
|
||||
declared Windows-native capabilities through the Gateway.
|
||||
|
||||
Common commands include:
|
||||
|
||||
- `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`,
|
||||
`canvas.snapshot`
|
||||
- `screen.snapshot` and, with explicit opt-in, `screen.record`
|
||||
- `camera.list` and, with explicit opt-in, `camera.snap`, `camera.clip`
|
||||
- `system.notify`, `system.run`, `system.run.prepare`, `system.which`
|
||||
- `location.get`, `device.info`, `device.status`
|
||||
- `stt.transcribe`, `tts.speak`
|
||||
|
||||
Node mode requires Gateway pairing. If the app shows a pairing request, approve
|
||||
it from the Gateway host:
|
||||
|
||||
```powershell
|
||||
openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOWS-HATCH-OK."
|
||||
openclaw devices list
|
||||
openclaw devices approve <request-id>
|
||||
openclaw nodes status
|
||||
```
|
||||
|
||||
Current caveats:
|
||||
The Gateway only forwards commands that the node declares and server policy
|
||||
allows. Privacy-sensitive commands such as `screen.record`, `camera.snap`, and
|
||||
`camera.clip` require explicit `gateway.nodes.allowCommands` opt-in.
|
||||
|
||||
- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health`
|
||||
- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first
|
||||
- if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately
|
||||
- if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever
|
||||
- Scheduled Tasks are still preferred when available because they provide better supervisor status
|
||||
## Local MCP mode
|
||||
|
||||
If you want the native CLI only, without gateway service install, use one of these:
|
||||
Windows Hub can expose the same Windows-native capability registry as a local
|
||||
MCP server on loopback. This is useful when you want local MCP clients to drive
|
||||
Windows capabilities without a running OpenClaw Gateway.
|
||||
|
||||
Enable it in Windows Hub Settings under the developer/advanced section. The app
|
||||
shows the loopback endpoint and bearer token after the server is enabled.
|
||||
|
||||
Mode matrix:
|
||||
|
||||
| Node mode | MCP server | Behavior |
|
||||
| --------- | ---------- | ---------------------------------- |
|
||||
| off | off | Operator-only desktop app |
|
||||
| on | off | Gateway-connected Windows node |
|
||||
| off | on | Local MCP server only |
|
||||
| on | on | Gateway node plus local MCP server |
|
||||
|
||||
## Native Windows CLI and Gateway
|
||||
|
||||
For terminal-first use, install OpenClaw from PowerShell:
|
||||
|
||||
```powershell
|
||||
openclaw onboard --non-interactive --skip-health
|
||||
openclaw gateway run
|
||||
iwr -useb https://openclaw.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
If you do want managed startup on native Windows:
|
||||
Verify:
|
||||
|
||||
```powershell
|
||||
openclaw --version
|
||||
openclaw doctor
|
||||
openclaw gateway status --json
|
||||
```
|
||||
|
||||
Native Windows CLI and Gateway flows are supported and continue to improve.
|
||||
Managed startup uses Windows Scheduled Tasks when available and falls back to a
|
||||
per-user Startup-folder login item if task creation is denied.
|
||||
|
||||
To install the Gateway service:
|
||||
|
||||
```powershell
|
||||
openclaw gateway install
|
||||
openclaw gateway status --json
|
||||
```
|
||||
|
||||
If Scheduled Task creation is blocked, the fallback service mode still auto-starts after login through the current user's Startup folder.
|
||||
If you only want CLI use without a managed Gateway service:
|
||||
|
||||
## Gateway
|
||||
|
||||
- [Gateway runbook](/gateway)
|
||||
- [Configuration](/gateway/configuration)
|
||||
|
||||
## Gateway service install (CLI)
|
||||
|
||||
Inside WSL2:
|
||||
|
||||
```
|
||||
openclaw onboard --install-daemon
|
||||
```powershell
|
||||
openclaw onboard --non-interactive --skip-health
|
||||
openclaw gateway run
|
||||
```
|
||||
|
||||
Or:
|
||||
## WSL2 Gateway
|
||||
|
||||
```
|
||||
openclaw gateway install
|
||||
WSL2 remains the most Linux-compatible Gateway runtime on Windows. Windows Hub
|
||||
can set up an app-owned WSL Gateway for you, or you can install manually inside
|
||||
your own distro.
|
||||
|
||||
Manual setup:
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
# Or pick a distro explicitly:
|
||||
wsl --list --online
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
|
||||
Or:
|
||||
Enable systemd inside WSL:
|
||||
|
||||
```
|
||||
openclaw configure
|
||||
```bash
|
||||
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
|
||||
[boot]
|
||||
systemd=true
|
||||
EOF
|
||||
```
|
||||
|
||||
Select **Gateway service** when prompted.
|
||||
|
||||
Repair/migrate:
|
||||
Restart WSL from PowerShell:
|
||||
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
```
|
||||
openclaw doctor
|
||||
|
||||
Then install OpenClaw inside WSL with the Linux quickstart:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash
|
||||
openclaw gateway status
|
||||
```
|
||||
|
||||
## Gateway auto-start before Windows login
|
||||
|
||||
For headless setups, ensure the full boot chain runs even when no one logs into
|
||||
Windows.
|
||||
|
||||
### 1) Keep user services running without login
|
||||
For headless WSL setups, ensure the full boot chain runs even when no one logs
|
||||
into Windows.
|
||||
|
||||
Inside WSL:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger "$(whoami)"
|
||||
```
|
||||
|
||||
### 2) Install the OpenClaw gateway user service
|
||||
|
||||
Inside WSL:
|
||||
|
||||
```bash
|
||||
openclaw gateway install
|
||||
```
|
||||
|
||||
### 3) Start WSL automatically at Windows boot
|
||||
|
||||
In PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
@@ -126,23 +200,20 @@ Replace `Ubuntu` with your distro name from:
|
||||
wsl --list --verbose
|
||||
```
|
||||
|
||||
### Verify startup chain
|
||||
|
||||
After a reboot (before Windows sign-in), check from WSL:
|
||||
After reboot, verify from WSL:
|
||||
|
||||
```bash
|
||||
systemctl --user is-enabled openclaw-gateway.service
|
||||
systemctl --user status openclaw-gateway.service --no-pager
|
||||
```
|
||||
|
||||
## Advanced: expose WSL services over LAN (portproxy)
|
||||
## Expose WSL services over LAN
|
||||
|
||||
WSL has its own virtual network. If another machine needs to reach a service
|
||||
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
|
||||
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
|
||||
so you may need to refresh the forwarding rule.
|
||||
WSL has its own virtual network. If another machine must reach a service inside
|
||||
WSL, forward a Windows port to the current WSL IP. The WSL IP can change after
|
||||
restarts, so refresh the forwarding rule when needed.
|
||||
|
||||
Example (PowerShell **as Administrator**):
|
||||
Example in PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
$Distro = "Ubuntu-24.04"
|
||||
@@ -154,112 +225,67 @@ if (-not $WslIp) { throw "WSL IP not found." }
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
|
||||
connectaddress=$WslIp connectport=$TargetPort
|
||||
```
|
||||
|
||||
Allow the port through Windows Firewall (one-time):
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
|
||||
-Protocol TCP -LocalPort $ListenPort -Action Allow
|
||||
```
|
||||
|
||||
Refresh the portproxy after WSL restarts:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
|
||||
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
|
||||
connectaddress=$WslIp connectport=$TargetPort | Out-Null
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
|
||||
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
|
||||
`openclaw status --all` to confirm.
|
||||
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
|
||||
- If you want this automatic, register a Scheduled Task to run the refresh
|
||||
step at login.
|
||||
- SSH from another machine targets the Windows host IP, for example
|
||||
`ssh user@windows-host -p 2222`.
|
||||
- Remote nodes must point at a reachable Gateway URL, not `127.0.0.1`.
|
||||
- Use `listenaddress=0.0.0.0` for LAN access. Use `127.0.0.1` for local-only
|
||||
access.
|
||||
|
||||
## Step-by-step WSL2 install
|
||||
## Troubleshooting
|
||||
|
||||
### 1) Install WSL2 + Ubuntu
|
||||
### The tray icon does not appear
|
||||
|
||||
Open PowerShell (Admin):
|
||||
Check Task Manager for `OpenClaw.Tray.WinUI.exe`. If it is running, open the
|
||||
hidden tray-icons area and pin it. If it is not running, launch **OpenClaw
|
||||
Companion** from the Start menu.
|
||||
|
||||
### Local setup fails
|
||||
|
||||
Open the setup log from Windows Hub or inspect:
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
# Or pick a distro explicitly:
|
||||
wsl --list --online
|
||||
wsl --install -d Ubuntu-24.04
|
||||
notepad "$env:LOCALAPPDATA\OpenClawTray\Logs\Setup\easy-setup-latest.txt"
|
||||
```
|
||||
|
||||
Reboot if Windows asks.
|
||||
Common causes are disabled WSL, blocked virtualization, stale app-owned WSL
|
||||
state, or a network failure while installing the Gateway package.
|
||||
|
||||
### 2) Enable systemd (required for gateway install)
|
||||
### The app says pairing is required
|
||||
|
||||
In your WSL terminal:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
|
||||
[boot]
|
||||
systemd=true
|
||||
EOF
|
||||
```
|
||||
|
||||
Then from PowerShell:
|
||||
Approve the operator or node request from the Gateway:
|
||||
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
openclaw devices list
|
||||
openclaw devices approve <request-id>
|
||||
```
|
||||
|
||||
Re-open Ubuntu, then verify:
|
||||
If the device already had a token, reconnect from the Connections tab after
|
||||
approval.
|
||||
|
||||
```bash
|
||||
systemctl --user status
|
||||
```
|
||||
### Web chat cannot reach a remote Gateway
|
||||
|
||||
### 3) Install OpenClaw (inside WSL)
|
||||
Remote web chat needs HTTPS or localhost. For self-signed certificates, trust
|
||||
the certificate in Windows, or use an SSH tunnel to a localhost URL.
|
||||
|
||||
For a normal first-time setup inside WSL, follow the Linux Getting Started flow:
|
||||
### `screen.snapshot`, camera, or audio commands fail
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm openclaw onboard --install-daemon
|
||||
```
|
||||
Confirm Windows permissions for camera, microphone, screen capture, and
|
||||
notifications. Packaged installs declare the protected capabilities, but Windows
|
||||
may still prompt the first time a command uses them.
|
||||
|
||||
If you are developing from source instead of doing first-time onboarding, use the
|
||||
source dev loop from [Setup](/start/setup):
|
||||
### Git or GitHub connectivity fails
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
# First run only (or after resetting local OpenClaw config/workspace)
|
||||
pnpm openclaw setup
|
||||
pnpm gateway:watch
|
||||
```
|
||||
Some networks block or throttle HTTPS to GitHub. If `git clone` or `gh auth
|
||||
login` fails, try another network, a VPN, or an HTTP/HTTPS proxy.
|
||||
|
||||
Full guide: [Getting Started](/start/getting-started)
|
||||
|
||||
## Windows companion app
|
||||
|
||||
We do not have a Windows companion app yet. Contributions are welcome if you want to
|
||||
help make it happen.
|
||||
|
||||
## Git and GitHub connectivity (contributors)
|
||||
|
||||
Some networks block or throttle HTTPS to GitHub. If `git clone` fails with timeouts
|
||||
or connection resets, try another network, a VPN, or an HTTP/HTTPS proxy your
|
||||
organization provides.
|
||||
|
||||
If `gh auth login` fails during the browser device flow (for example a timeout
|
||||
reaching `github.com:443`), authenticate with a personal access token instead:
|
||||
|
||||
1. Create a token with at least the `repo` scope (classic PAT) or equivalent
|
||||
fine-grained access.
|
||||
2. In PowerShell for the current session:
|
||||
For token-based `gh` auth in the current session:
|
||||
|
||||
```powershell
|
||||
$env:GH_TOKEN="<your-token>"
|
||||
@@ -267,20 +293,12 @@ gh auth status
|
||||
gh auth setup-git
|
||||
```
|
||||
|
||||
3. If `gh auth status` warns about missing `read:org`, mint a token that includes
|
||||
that scope and re-assign the variable:
|
||||
|
||||
```powershell
|
||||
$env:GH_TOKEN="<your-token-with-repo-and-read:org>"
|
||||
gh auth status
|
||||
```
|
||||
|
||||
`gh auth refresh -s read:org` only applies when you authenticated via `gh auth login`
|
||||
and have stored credentials to refresh (not when using `GH_TOKEN`).
|
||||
|
||||
Never commit tokens or paste them into issues or pull requests.
|
||||
|
||||
## Related
|
||||
|
||||
- [Install overview](/install)
|
||||
- [Platforms](/platforms)
|
||||
- [Node.js setup](/install/node)
|
||||
- [Nodes](/nodes)
|
||||
- [Control UI](/web/control-ui)
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
|
||||
@@ -38,14 +38,14 @@ All Codex harness settings live under `plugins.entries.codex.config`.
|
||||
|
||||
Supported top-level fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
|
||||
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
|
||||
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
|
||||
| Field | Default | Meaning |
|
||||
| -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
|
||||
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
|
||||
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
|
||||
|
||||
## App-server transport
|
||||
|
||||
|
||||
@@ -526,7 +526,7 @@ Supported top-level Codex plugin fields:
|
||||
| -------------------------- | -------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. |
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ summary: "Configure migrated native Codex plugins for Codex-mode OpenClaw agents
|
||||
title: "Native Codex plugins"
|
||||
read_when:
|
||||
- You want Codex-mode OpenClaw agents to use native Codex plugins
|
||||
- You are configuring first-party Codex plugin marketplaces
|
||||
- You are migrating source-installed openai-curated Codex plugins
|
||||
- You are troubleshooting codexPlugins, app inventory, destructive actions, or plugin app diagnostics
|
||||
---
|
||||
|
||||
@@ -22,9 +22,7 @@ Use this page after the base [Codex harness](/plugins/codex-harness) is working.
|
||||
- The selected OpenClaw agent runtime must be the native Codex harness.
|
||||
- `plugins.entries.codex.enabled` must be true.
|
||||
- `plugins.entries.codex.config.codexPlugins.enabled` must be true.
|
||||
- V1 supports first-party Codex plugin marketplaces: `openai-curated`,
|
||||
`openai-bundled`, and `openai-primary-runtime`.
|
||||
- Migration only auto-discovers `openai-curated` plugins that it observed as
|
||||
- V1 supports only `openai-curated` plugins that migration observed as
|
||||
source-installed in the source Codex home.
|
||||
- The target Codex app-server must be able to see the expected marketplace,
|
||||
plugin, and app inventory.
|
||||
@@ -58,11 +56,9 @@ Apply the migration when the plan looks right:
|
||||
openclaw migrate apply codex --yes
|
||||
```
|
||||
|
||||
Migration writes explicit `codexPlugins` entries for eligible curated plugins
|
||||
and calls Codex app-server `plugin/install` for selected plugins. Explicit
|
||||
config may also reference Codex's bundled and primary-runtime first-party
|
||||
marketplaces when the target app-server inventory exposes those plugin apps. A
|
||||
typical migrated config looks like this:
|
||||
Migration writes explicit `codexPlugins` entries for eligible plugins and calls
|
||||
Codex app-server `plugin/install` for selected plugins. A typical migrated
|
||||
config looks like this:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -93,49 +89,6 @@ After changing `codexPlugins`, new Codex conversations pick up the updated app
|
||||
set automatically. Use `/new` or `/reset` to refresh the current conversation.
|
||||
A gateway restart is not required for plugin enable or disable changes.
|
||||
|
||||
## Manual first-party marketplace entries
|
||||
|
||||
Migration writes `openai-curated` entries for eligible source-installed plugins.
|
||||
For first-party plugins that live in Codex's bundled or primary-runtime
|
||||
marketplaces, add explicit entries after confirming the target Codex app-server
|
||||
inventory exposes that marketplace and plugin.
|
||||
|
||||
Use the same config shape for every first-party marketplace:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
chrome: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-bundled",
|
||||
pluginName: "chrome",
|
||||
},
|
||||
documents: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-primary-runtime",
|
||||
pluginName: "documents",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The key under `plugins` is OpenClaw's local config key. `pluginName` and
|
||||
`marketplaceName` must match the Codex app-server inventory exactly. If the
|
||||
plugin is not listed in `/codex plugins list` or Codex app diagnostics, OpenClaw
|
||||
keeps the entry configured but cannot expose its apps to Codex turns.
|
||||
|
||||
## Manage plugins from chat
|
||||
|
||||
Use `/codex plugins` when you want to inspect or change configured native Codex
|
||||
@@ -197,10 +150,8 @@ up the updated app set.
|
||||
|
||||
V1 is intentionally narrow:
|
||||
|
||||
- Runtime config accepts `openai-curated`, `openai-bundled`, and
|
||||
`openai-primary-runtime` plugin identities.
|
||||
- Only `openai-curated` plugins that were already installed in the source Codex
|
||||
app-server inventory are migration-eligible for automatic migration.
|
||||
app-server inventory are migration-eligible.
|
||||
- App-backed source plugins must pass the migration-time subscription gate.
|
||||
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
|
||||
accounts plus, in verification mode, inaccessible, disabled, missing source
|
||||
@@ -213,9 +164,7 @@ V1 is intentionally narrow:
|
||||
- There is no `plugins["*"]` wildcard and no config key that grants arbitrary
|
||||
install authority.
|
||||
- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config files
|
||||
are preserved in the migration report for manual review. Bundled and
|
||||
primary-runtime first-party plugins can still be added manually through
|
||||
explicit `codexPlugins` config.
|
||||
are preserved in the migration report for manual review.
|
||||
|
||||
## App inventory and ownership
|
||||
|
||||
@@ -303,10 +252,8 @@ app-server auth or rerun with `--verify-plugin-apps` if you want source app
|
||||
inventory to decide eligibility when account lookup fails.
|
||||
|
||||
**`marketplace_missing` or `plugin_missing`:** the target Codex app-server
|
||||
cannot see the expected first-party marketplace or plugin. Rerun migration
|
||||
against the target runtime, inspect Codex app-server plugin status, or confirm
|
||||
the explicit `marketplaceName` is one of `openai-curated`, `openai-bundled`, or
|
||||
`openai-primary-runtime`.
|
||||
cannot see the expected `openai-curated` marketplace or plugin. Rerun migration
|
||||
against the target runtime or inspect Codex app-server plugin status.
|
||||
|
||||
**`app_inventory_missing` or `app_inventory_stale`:** app readiness came from an
|
||||
empty or stale cache. OpenClaw schedules an async refresh and excludes plugin
|
||||
|
||||
@@ -152,7 +152,7 @@ observation-only.
|
||||
- `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway
|
||||
- `deactivate` - deprecated compatibility alias for `gateway_stop`; use `gateway_stop` in new plugins
|
||||
- `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
|
||||
- **`before_install`** - inspect skill or plugin install scans and optionally block
|
||||
- **`before_install`** - inspect skill or plugin install context and optionally block
|
||||
|
||||
## Debug runtime hooks
|
||||
|
||||
@@ -452,11 +452,14 @@ Decision rules:
|
||||
|
||||
## Install hooks
|
||||
|
||||
`before_install` runs after the built-in scan for skill and plugin installs.
|
||||
Return additional findings or `{ block: true, blockReason }` to stop the
|
||||
install.
|
||||
`before_install` runs after the operator-owned `security.installPolicy` check
|
||||
when one is configured. The `builtinScan` field remains in the event payload for
|
||||
compatibility, but OpenClaw no longer runs built-in install-time dangerous-code
|
||||
blocking, so it is an empty `ok` result. Return additional findings or
|
||||
`{ block: true, blockReason }` to stop the install.
|
||||
|
||||
`block: true` is terminal. `block: false` is treated as no decision.
|
||||
Handler failures block the install fail-closed.
|
||||
|
||||
## Gateway lifecycle
|
||||
|
||||
|
||||
@@ -153,6 +153,10 @@ the install instead.
|
||||
| npm pack | You are proving a local package artifact through npm install semantics | `openclaw plugins install npm-pack:<path.tgz>` |
|
||||
| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install <plugin> --marketplace <source>` |
|
||||
|
||||
Managed local path installs must be plugin directories or archives. Put
|
||||
standalone plugin files in `plugins.load.paths` instead of installing them with
|
||||
`plugins install`.
|
||||
|
||||
## Publish plugins
|
||||
|
||||
ClawHub is the primary public discovery surface for OpenClaw plugins. Publish
|
||||
|
||||
@@ -27,7 +27,7 @@ settings and governed workspace declarations. Policy currently covers channel
|
||||
conformance, governed tool metadata, MCP server posture, model-provider posture,
|
||||
private-network access posture, Gateway exposure posture, agent workspace/tool
|
||||
posture, configured global/per-agent tool posture, configured sandbox runtime
|
||||
posture, ingress/channel access posture, and OpenClaw config secret
|
||||
posture, ingress/channel access posture, data-handling posture, and OpenClaw config secret
|
||||
provider/auth profile posture.
|
||||
|
||||
Policy stores authored requirements in `policy.jsonc`, observes existing
|
||||
@@ -55,9 +55,16 @@ and require sandbox browser CDP source ranges.
|
||||
These checks observe config conformance only; they do not read runtime approval
|
||||
state, inspect live containers, or add runtime enforcement.
|
||||
|
||||
Data-handling rules can require sensitive logging redaction, deny telemetry
|
||||
content capture, require session retention maintenance, and deny session
|
||||
transcript memory indexing. These checks observe config conformance only; they
|
||||
do not inspect raw logs, telemetry exports, transcripts, memory files, secrets,
|
||||
or personal data.
|
||||
|
||||
Named policy scopes under `scopes.<scopeName>` can add stricter normal policy
|
||||
sections for the selector they list. `agentIds` supports `tools`,
|
||||
`agents.workspace`, and `sandbox`; `channelIds` supports `ingress.channels`.
|
||||
`agents.workspace`, `sandbox`, and `dataHandling.memory`; `channelIds` supports
|
||||
`ingress.channels`.
|
||||
Runtime agent ids that are not explicitly listed in `agents.list[]` are checked
|
||||
against inherited global/default posture rather than silently passing with no
|
||||
evidence. Every scope present in `policy.jsonc` must be valid and enforceable
|
||||
|
||||
@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
|
||||
2. Create a card with a title, notes, priority, labels, optional agent, and
|
||||
optional linked session.
|
||||
3. Or open Sessions and choose Add to Workboard for an existing session.
|
||||
4. Drag the card between columns or use the column controls.
|
||||
4. Drag the card between columns or focus the compact status control on the card
|
||||
and use its menu or ArrowLeft/ArrowRight.
|
||||
5. Start work from the card to create or reuse a dashboard session.
|
||||
6. Open the linked session from the card while the agent works.
|
||||
7. Let lifecycle sync move running work into review or blocked, then manually
|
||||
|
||||
@@ -25,9 +25,10 @@ OpenClaw has three public release lanes:
|
||||
- `latest` means the current promoted stable npm release
|
||||
- `beta` means the current beta install target
|
||||
- Stable and stable correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later
|
||||
- Every stable OpenClaw release ships the npm package and macOS app together;
|
||||
beta releases normally validate and publish the npm/package path first, with
|
||||
mac app build/sign/notarize reserved for stable unless explicitly requested
|
||||
- Every stable OpenClaw release ships the npm package, macOS app, and signed
|
||||
Windows Hub installers together; beta releases normally validate and publish
|
||||
the npm/package path first, with native app build/sign/notarize/promote
|
||||
reserved for stable unless explicitly requested
|
||||
|
||||
## Release cadence
|
||||
|
||||
@@ -119,7 +120,12 @@ vYYYY.M.D-beta.N` from the matching `release/YYYY.M.D` branch. The helper runs
|
||||
packaged `.zip`, `.dmg`, `.dSYM.zip`, and updated `appcast.xml` on `main`.
|
||||
The macOS publish workflow publishes the signed appcast to public `main`
|
||||
automatically after release assets verify; if branch protection blocks the
|
||||
direct push, it opens or updates an appcast PR.
|
||||
direct push, it opens or updates an appcast PR. Stable Windows Hub
|
||||
readiness requires the signed `OpenClawCompanion-Setup-x64.exe`,
|
||||
`OpenClawCompanion-Setup-arm64.exe`, and
|
||||
`OpenClawCompanion-SHA256SUMS.txt` assets on the OpenClaw GitHub release;
|
||||
promote them with the `Windows Node Release` workflow after the matching
|
||||
`openclaw/openclaw-windows-node` release has passed its signing workflow.
|
||||
11. After publish, run the npm post-publish verifier, optional standalone
|
||||
published-npm Telegram E2E when you need post-publish channel proof,
|
||||
dist-tag promotion when needed, verify the generated GitHub release page,
|
||||
@@ -232,6 +238,15 @@ vYYYY.M.D-beta.N` from the matching `release/YYYY.M.D` branch. The helper runs
|
||||
workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw
|
||||
npm publish so the core package is not published before its externalized
|
||||
plugins.
|
||||
- Run the manual `Windows Node Release` workflow for stable releases after the
|
||||
matching `openclaw/openclaw-windows-node` release exists. It downloads the
|
||||
signed Windows Hub installers from the companion repo, verifies their
|
||||
Authenticode signatures on a Windows runner, writes a SHA-256 manifest, and
|
||||
uploads the installers plus manifest onto the canonical OpenClaw GitHub
|
||||
release. Website download links should target exact OpenClaw release asset
|
||||
URLs for the current stable release, or `releases/latest/download/...` only
|
||||
after verifying GitHub's latest redirect points at that same release; do not
|
||||
link only to the companion repo release page.
|
||||
- Release checks now run in a separate manual workflow:
|
||||
`OpenClaw Release Checks`
|
||||
- `OpenClaw Release Checks` also runs the QA Lab mock parity lane plus the fast
|
||||
|
||||
@@ -78,7 +78,7 @@ OpenClaw resolves these via `src/config/sessions.ts`.
|
||||
|
||||
Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json`, transcript artifacts, and trajectory sidecars:
|
||||
|
||||
- `mode`: `warn` (default) or `enforce`
|
||||
- `mode`: `enforce` (default) or `warn`
|
||||
- `pruneAfter`: stale-entry age cutoff (default `30d`)
|
||||
- `maxEntries`: cap entries in `sessions.json` (default `500`)
|
||||
- `resetArchiveRetention`: retention for `*.reset.<timestamp>` transcript archives (default: same as `pruneAfter`; `false` disables cleanup)
|
||||
|
||||
@@ -17,8 +17,8 @@ and a working chat session.
|
||||
|
||||
<Tip>
|
||||
Check your Node version with `node --version`.
|
||||
**Windows users:** both native Windows and WSL2 are supported. WSL2 is more
|
||||
stable and recommended for the full experience. See [Windows](/platforms/windows).
|
||||
**Windows users:** the native Windows Hub app is the easiest desktop path. The
|
||||
PowerShell installer and WSL2 Gateway paths are also supported. See [Windows](/platforms/windows).
|
||||
Need to install Node? See [Node setup](/install/node).
|
||||
</Tip>
|
||||
|
||||
|
||||
@@ -144,9 +144,15 @@ when set at the narrower session or agent scope.
|
||||
### `exec.ask`
|
||||
|
||||
<ParamField path="ask" type='"off" | "on-miss" | "always"'>
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
Configured ask policy for host exec. Controls the baseline approval
|
||||
prompt behavior from `tools.exec.ask` and host approvals defaults. The
|
||||
per-call `ask` tool parameter (see [Exec tool](/tools/exec#parameters))
|
||||
can only harden that baseline, and channel-origin model calls ignore it
|
||||
when the effective host ask is `off`.
|
||||
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
|
||||
</ParamField>
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ force `security=full` only when the operator explicitly grants elevated access.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="ask" type="'off' | 'on-miss' | 'always'">
|
||||
Approval prompt behavior for `gateway` / `node` execution.
|
||||
The baseline ask mode comes from `tools.exec.ask` and host approvals.
|
||||
For channel-origin model calls, per-call `ask` is ignored when the
|
||||
effective host ask is `off`; otherwise it can only harden to a stricter
|
||||
mode. Trusted internal/API callers that construct exec tools with an
|
||||
explicit `ask` value are unchanged.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="node" type="string">
|
||||
|
||||
@@ -143,6 +143,19 @@ current latest release declares a newer `openclaw.compat.pluginApi` or
|
||||
and installs the newest one that fits. Exact versions and explicit channel tags
|
||||
such as `@beta` stay pinned to the selected package and fail when incompatible.
|
||||
|
||||
### Operator install policy
|
||||
|
||||
Configure `security.installPolicy` to run a trusted local policy command before
|
||||
plugin install or update proceeds. The policy receives metadata plus the staged
|
||||
source path and can allow or block the install. It runs before plugin
|
||||
`before_install` hooks. The deprecated `--dangerously-force-unsafe-install`
|
||||
flag is accepted for compatibility but does not bypass install policy, hooks, or
|
||||
OpenClaw's built-in plugin dependency denylist.
|
||||
|
||||
See [Skills config](/tools/skills-config#operator-install-policy-securityinstallpolicy)
|
||||
for the shared `security.installPolicy` exec schema used by both skills and
|
||||
plugins.
|
||||
|
||||
### Configure plugin policy
|
||||
|
||||
The common plugin config shape is:
|
||||
@@ -172,7 +185,9 @@ Key policy rules:
|
||||
allowlist stay unavailable, even when `tools.allow` includes `"*"`.
|
||||
- `plugins.entries.<id>.enabled: false` disables one plugin while preserving its
|
||||
config.
|
||||
- `plugins.load.paths` adds explicit local plugin files or directories.
|
||||
- `plugins.load.paths` adds explicit local plugin files or directories. Managed
|
||||
`plugins install` local paths must be plugin directories or archives; use
|
||||
`plugins.load.paths` for standalone plugin files.
|
||||
- Workspace-origin plugins are disabled by default; explicitly enable or
|
||||
allowlist them before using local workspace code.
|
||||
- Bundled plugins follow their built-in default-on/default-off metadata unless
|
||||
|
||||
@@ -95,6 +95,167 @@ Most skills configuration lives under `skills` in
|
||||
need this setting.
|
||||
</ParamField>
|
||||
|
||||
## Operator Install Policy (`security.installPolicy`)
|
||||
|
||||
Use `security.installPolicy` when operators need a trusted local command to
|
||||
approve or block skill and plugin installs with host-specific policy. The policy
|
||||
runs after OpenClaw has staged source material and before the install or update
|
||||
continues. It applies to ClawHub skills, uploaded skills, Git/local skills,
|
||||
skill dependency installers, and plugin install/update sources.
|
||||
|
||||
```json5
|
||||
{
|
||||
security: {
|
||||
installPolicy: {
|
||||
enabled: true,
|
||||
// Omit targets to cover every supported target.
|
||||
targets: ["skill", "plugin"],
|
||||
exec: {
|
||||
source: "exec",
|
||||
command: "/usr/local/bin/openclaw-install-policy",
|
||||
args: ["--json"],
|
||||
timeoutMs: 10000,
|
||||
noOutputTimeoutMs: 10000,
|
||||
maxOutputBytes: 1048576,
|
||||
passEnv: ["OPENCLAW_STATE_DIR", "PATH"],
|
||||
env: { POLICY_MODE: "strict" },
|
||||
trustedDirs: ["/usr/local/bin"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<ParamField path="security.installPolicy.enabled" type="boolean" default="false">
|
||||
Enables operator-owned install policy. When enabled without a valid `exec`
|
||||
command, installs fail closed.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.targets" type='("skill" | "plugin")[]'>
|
||||
Optional target filter. When omitted, policy applies to every supported target
|
||||
so new installs do not unexpectedly fail open.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.command" type="string">
|
||||
Absolute path to the trusted policy executable. OpenClaw runs it without a
|
||||
shell and validates the path before use.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.args" type="string[]">
|
||||
Static arguments passed after `command`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.timeoutMs" type="number" default="10000">
|
||||
Maximum wall-clock runtime for one policy decision.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.noOutputTimeoutMs" type="number" default="timeoutMs">
|
||||
Maximum time without stdout or stderr output before the policy fails closed.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.maxOutputBytes" type="number" default="1048576">
|
||||
Maximum combined stdout and stderr bytes accepted from the policy process.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.env" type="Record<string, string>">
|
||||
Literal environment variables provided to the policy process.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.passEnv" type="string[]">
|
||||
Environment variable names copied from the OpenClaw process into the policy
|
||||
process. Only named variables are passed.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.trustedDirs" type="string[]">
|
||||
Optional allowlist of directories that may contain the policy executable.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.allowInsecurePath" type="boolean" default="false">
|
||||
Bypasses command path ownership and permission checks. Use only when the path
|
||||
is protected by another mechanism.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="security.installPolicy.exec.allowSymlinkCommand" type="boolean" default="false">
|
||||
Allows the configured command path to be a symlink. The resolved target must
|
||||
still satisfy the other path checks. Interpreter script arguments must be
|
||||
direct regular files, not symlinks.
|
||||
</ParamField>
|
||||
|
||||
The policy receives one JSON object on stdin with `protocolVersion: 1`,
|
||||
`openclawVersion`, `targetType`, `targetName`, `sourcePath`, `sourcePathKind`,
|
||||
optional structured `source`, structured `origin`, and `request`. It must write
|
||||
one JSON object on stdout: `{ "protocolVersion": 1, "decision": "allow" }` or
|
||||
`{ "protocolVersion": 1, "decision": "block", "reason": "..." }`. Non-zero
|
||||
exit, timeout, malformed JSON, missing fields, or unsupported protocol versions
|
||||
fail closed.
|
||||
|
||||
OpenClaw does not execute install policy during normal Gateway startup. Installs
|
||||
and updates fail closed when policy is enabled but unavailable. `openclaw doctor`
|
||||
performs static validation, and `openclaw doctor --deep` executes a synthetic
|
||||
install probe against the configured command.
|
||||
|
||||
Bulk updates apply policy per target: a blocked skill or plugin update fails
|
||||
that target without disabling the policy or skipping later targets in the batch.
|
||||
|
||||
Example stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"openclawVersion": "2026.6.1",
|
||||
"targetType": "skill",
|
||||
"targetName": "weather",
|
||||
"sourcePath": "/var/folders/.../openclaw-skill-clawhub/root",
|
||||
"sourcePathKind": "directory",
|
||||
"source": {
|
||||
"kind": "clawhub",
|
||||
"authority": "openclaw",
|
||||
"mutable": false,
|
||||
"network": true
|
||||
},
|
||||
"origin": {
|
||||
"type": "clawhub",
|
||||
"registry": "https://clawhub.openclaw.ai",
|
||||
"slug": "weather",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"request": {
|
||||
"kind": "skill-install",
|
||||
"mode": "install",
|
||||
"requestedSpecifier": "clawhub:weather@1.0.0"
|
||||
},
|
||||
"skill": {
|
||||
"installId": "clawhub"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal policy command:
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
const request = JSON.parse(input);
|
||||
if (request.targetType === "plugin" && request.source?.kind === "local-path") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
decision: "block",
|
||||
reason: "local plugin paths are not approved on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" }));
|
||||
});
|
||||
```
|
||||
|
||||
## Bundled skill allowlist
|
||||
|
||||
<ParamField path="skills.allowBundled" type="string[]">
|
||||
|
||||
@@ -208,12 +208,12 @@ publish and sync.
|
||||
symlinked skill folders, but every `SKILL.md` realpath must still stay
|
||||
inside its resolved skill directory.
|
||||
</Accordion>
|
||||
<Accordion title="Scan and scan overrides">
|
||||
Gateway-backed skill installs (onboarding, Skills settings UI) run the
|
||||
built-in dangerous-code scanner before executing installer metadata.
|
||||
`critical` findings block by default; `suspicious` findings warn only.
|
||||
`openclaw skills install <slug>` downloads a ClawHub skill folder directly
|
||||
and does not use the installer-metadata scanner.
|
||||
<Accordion title="Operator install policy">
|
||||
Configure `security.installPolicy` to run a trusted local policy command
|
||||
before skill installs continue. The policy receives metadata and the staged
|
||||
source path, applies to ClawHub, uploaded, Git, local, update, and
|
||||
dependency-installer paths, and fails closed when the command cannot return
|
||||
a valid decision.
|
||||
</Accordion>
|
||||
<Accordion title="Secret injection scope">
|
||||
`skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the
|
||||
|
||||
@@ -180,7 +180,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Send and history semantics">
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. Trusted Control UI clients may also receive optional ACK timing metadata for local diagnostics.
|
||||
- Chat uploads accept images plus non-video files. Images keep the native image path; other files are stored as managed media and shown in history as attachment links.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
|
||||
@@ -112,9 +113,17 @@ function baseProfileContext() {
|
||||
};
|
||||
}
|
||||
|
||||
function createRouteContext(profileCtx: ProfileContext, options?: { ssrfPolicy?: unknown }) {
|
||||
function createRouteContext(
|
||||
profileCtx: ProfileContext,
|
||||
options?: { actionTimeoutMs?: number; ssrfPolicy?: unknown },
|
||||
) {
|
||||
return {
|
||||
state: () => ({ resolved: { ssrfPolicy: options?.ssrfPolicy } }),
|
||||
state: () => ({
|
||||
resolved: {
|
||||
actionTimeoutMs: options?.actionTimeoutMs ?? 45_000,
|
||||
ssrfPolicy: options?.ssrfPolicy,
|
||||
},
|
||||
}),
|
||||
forProfile: () => profileCtx,
|
||||
listProfiles: vi.fn(async () => []),
|
||||
mapTabError: vi.fn((err: unknown) => {
|
||||
@@ -143,31 +152,51 @@ async function callTabsRoute(params: {
|
||||
path: "/tabs" | "/tabs/action" | "/tabs/focus";
|
||||
body?: Record<string, unknown>;
|
||||
profileCtx: ProfileContext;
|
||||
actionTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
ssrfPolicy?: unknown;
|
||||
}) {
|
||||
const { app, getHandlers, postHandlers } = createBrowserRouteApp();
|
||||
registerBrowserTabRoutes(
|
||||
app,
|
||||
createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
|
||||
createRouteContext(params.profileCtx, {
|
||||
actionTimeoutMs: params.actionTimeoutMs,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
}) as never,
|
||||
);
|
||||
const handler =
|
||||
params.method === "get" ? getHandlers.get(params.path) : postHandlers.get(params.path);
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const response = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: {}, body: params.body ?? {} }, response.res);
|
||||
await handler?.(
|
||||
{
|
||||
params: {},
|
||||
query: {},
|
||||
body: params.body ?? {},
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
},
|
||||
response.res,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function callTabsAction(params: {
|
||||
body: Record<string, unknown>;
|
||||
profileCtx: ProfileContext;
|
||||
actionTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
ssrfPolicy?: unknown;
|
||||
}) {
|
||||
return await callTabsRoute({ ...params, method: "post", path: "/tabs/action" });
|
||||
}
|
||||
|
||||
async function callTabsList(params: { profileCtx: ProfileContext; ssrfPolicy?: unknown }) {
|
||||
async function callTabsList(params: {
|
||||
profileCtx: ProfileContext;
|
||||
actionTimeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
ssrfPolicy?: unknown;
|
||||
}) {
|
||||
return await callTabsRoute({ ...params, method: "get", path: "/tabs" });
|
||||
}
|
||||
|
||||
@@ -197,6 +226,62 @@ describe("browser tab routes", () => {
|
||||
await expectBrowserNotRunningAction("select");
|
||||
});
|
||||
|
||||
it("uses the configured action timeout for existing-session tab reachability", async () => {
|
||||
const isReachable = vi.fn(async () => true);
|
||||
const abort = new AbortController();
|
||||
const profileCtx = createProfileContext({
|
||||
profile: {
|
||||
...baseProfileContext().profile,
|
||||
driver: "existing-session",
|
||||
} as never,
|
||||
isReachable,
|
||||
});
|
||||
|
||||
const listResponse = await callTabsList({ profileCtx, signal: abort.signal });
|
||||
const actionResponse = await callTabsAction({
|
||||
profileCtx,
|
||||
body: { action: "list" },
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(actionResponse.statusCode).toBe(200);
|
||||
expect(isReachable).toHaveBeenNthCalledWith(1, 45_000, { signal: abort.signal });
|
||||
expect(isReachable).toHaveBeenNthCalledWith(2, 45_000, { signal: abort.signal });
|
||||
});
|
||||
|
||||
it("keeps the short reachability probe for non-Chrome-MCP tab routes", async () => {
|
||||
const isReachable = vi.fn(async () => true);
|
||||
const profileCtx = createProfileContext({ isReachable });
|
||||
|
||||
const response = await callTabsList({ profileCtx });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(isReachable).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
it("normalizes configured existing-session tab reachability timeouts", async () => {
|
||||
const isReachable = vi.fn(async () => true);
|
||||
const profileCtx = createProfileContext({
|
||||
profile: {
|
||||
...baseProfileContext().profile,
|
||||
driver: "existing-session",
|
||||
} as never,
|
||||
isReachable,
|
||||
});
|
||||
|
||||
const zeroResponse = await callTabsList({ profileCtx, actionTimeoutMs: 0 });
|
||||
expect(zeroResponse.statusCode).toBe(200);
|
||||
expect(isReachable).toHaveBeenLastCalledWith(300);
|
||||
|
||||
const hugeResponse = await callTabsList({
|
||||
profileCtx,
|
||||
actionTimeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
expect(hugeResponse.statusCode).toBe(200);
|
||||
expect(isReachable).toHaveBeenLastCalledWith(MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("redacts blocked tab URLs from GET /tabs", async () => {
|
||||
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
|
||||
async (opts?: { url: string }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { clampPositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
BrowserProfileUnavailableError,
|
||||
BrowserTabNotFoundError,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "../navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import { resolveTargetIdFromTabs } from "../target-id.js";
|
||||
import { browserNavigationPolicyForProfile, resolveProfileContext } from "./agent.shared.js";
|
||||
@@ -15,6 +17,8 @@ import { readRouteNonNegativeInteger } from "./route-numeric.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
const DEFAULT_TAB_REACHABILITY_TIMEOUT_MS = 300;
|
||||
|
||||
function handleTabsRouteError(
|
||||
ctx: BrowserRouteContext,
|
||||
res: BrowserResponse,
|
||||
@@ -48,8 +52,37 @@ async function withTabsProfileRoute(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
|
||||
if (!(await profileCtx.isReachable(300))) {
|
||||
function resolveTabReachabilityTimeoutMs(
|
||||
ctx: BrowserRouteContext,
|
||||
profileCtx: ProfileContext,
|
||||
): number {
|
||||
if (!getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return DEFAULT_TAB_REACHABILITY_TIMEOUT_MS;
|
||||
}
|
||||
return (
|
||||
clampPositiveTimerTimeoutMs(ctx.state().resolved.actionTimeoutMs) ??
|
||||
DEFAULT_TAB_REACHABILITY_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function checkTabReachability(
|
||||
ctx: BrowserRouteContext,
|
||||
profileCtx: ProfileContext,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const timeoutMs = resolveTabReachabilityTimeoutMs(ctx, profileCtx);
|
||||
return signal
|
||||
? await profileCtx.isReachable(timeoutMs, { signal })
|
||||
: await profileCtx.isReachable(timeoutMs);
|
||||
}
|
||||
|
||||
async function ensureBrowserRunning(
|
||||
ctx: BrowserRouteContext,
|
||||
profileCtx: ProfileContext,
|
||||
res: BrowserResponse,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!(await checkTabReachability(ctx, profileCtx, signal))) {
|
||||
jsonError(
|
||||
res,
|
||||
new BrowserProfileUnavailableError("browser not running").status,
|
||||
@@ -149,7 +182,7 @@ async function runTabTargetMutation(params: {
|
||||
ctx: params.ctx,
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
if (!(await ensureBrowserRunning(profileCtx, params.res))) {
|
||||
if (!(await ensureBrowserRunning(params.ctx, profileCtx, params.res, params.req.signal))) {
|
||||
return;
|
||||
}
|
||||
await params.mutate(profileCtx, params.targetId);
|
||||
@@ -167,7 +200,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
res,
|
||||
ctx,
|
||||
run: async (profileCtx) => {
|
||||
const reachable = await profileCtx.isReachable(300);
|
||||
const reachable = await checkTabReachability(ctx, profileCtx, req.signal);
|
||||
if (!reachable) {
|
||||
return res.json({ running: false, tabs: [] as unknown[] });
|
||||
}
|
||||
@@ -277,7 +310,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
if (action === "list") {
|
||||
const reachable = await profileCtx.isReachable(300);
|
||||
const reachable = await checkTabReachability(ctx, profileCtx, req.signal);
|
||||
if (!reachable) {
|
||||
return res.json({ ok: true, tabs: [] as unknown[] });
|
||||
}
|
||||
@@ -297,7 +330,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
}
|
||||
|
||||
if (action === "label") {
|
||||
if (!(await ensureBrowserRunning(profileCtx, res))) {
|
||||
if (!(await ensureBrowserRunning(ctx, profileCtx, res, req.signal))) {
|
||||
return;
|
||||
}
|
||||
const targetId = parseRequiredTargetId(
|
||||
@@ -316,7 +349,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
}
|
||||
|
||||
if (action === "close") {
|
||||
if (!(await ensureBrowserRunning(profileCtx, res))) {
|
||||
if (!(await ensureBrowserRunning(ctx, profileCtx, res, req.signal))) {
|
||||
return;
|
||||
}
|
||||
const index = readTabIndex(res, req.body);
|
||||
@@ -337,7 +370,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
if (index === null || index === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!(await ensureBrowserRunning(profileCtx, res))) {
|
||||
if (!(await ensureBrowserRunning(ctx, profileCtx, res, req.signal))) {
|
||||
return;
|
||||
}
|
||||
const tabs = await profileCtx.listTabs();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
||||
@@ -6,6 +7,24 @@ import { copyA2uiAssets } from "./copy-a2ui.mjs";
|
||||
|
||||
const ORIGINAL_SKIP_MISSING = process.env.OPENCLAW_A2UI_SKIP_MISSING;
|
||||
const ORIGINAL_SPARSE_PROFILE = process.env.OPENCLAW_SPARSE_PROFILE;
|
||||
const REQUIRED_COMPATIBILITY_ASSETS = [
|
||||
{
|
||||
path: path.join("assets", "providers", "google.png"),
|
||||
sha256: "cea7e50b816514db6ca0f21d9545173fae1669643c71ed475c45c7f8440dac53",
|
||||
},
|
||||
{
|
||||
path: path.join("assets", "providers", "x.png"),
|
||||
sha256: "307c5dbde1ad66164fcfa1d9787435d99906fa78e7ba7d068f2aa705e86ff5aa",
|
||||
},
|
||||
{
|
||||
path: "granola.png",
|
||||
sha256: "16bc6b7f1b1229c8b1984c64520c30141b62c24b156c7590f86ca50bdc494d34",
|
||||
},
|
||||
];
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
describe("canvas a2ui copy", () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,6 +53,19 @@ describe("canvas a2ui copy", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("ships provider assets and the legacy granola compatibility image", async () => {
|
||||
const srcDir = path.join(process.cwd(), "extensions", "canvas", "src", "host", "a2ui");
|
||||
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
for (const asset of REQUIRED_COMPATIBILITY_ASSETS) {
|
||||
const bytes = await fs.readFile(path.join(srcDir, asset.path));
|
||||
|
||||
expect([...bytes.subarray(0, pngSignature.length)]).toEqual(pngSignature);
|
||||
expect(bytes.length).toBeGreaterThan(64);
|
||||
expect(sha256(bytes)).toBe(asset.sha256);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a helpful error when assets are missing", async () => {
|
||||
await withA2uiFixture(async (dir) => {
|
||||
await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow(
|
||||
@@ -78,4 +110,30 @@ describe("canvas a2ui copy", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provider assets and the legacy granola compatibility image", async () => {
|
||||
await withA2uiFixture(async (dir) => {
|
||||
const srcDir = path.join(dir, "src");
|
||||
const outDir = path.join(dir, "dist");
|
||||
const providerAssetDir = path.join(srcDir, "assets", "providers");
|
||||
await fs.mkdir(providerAssetDir, { recursive: true });
|
||||
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
|
||||
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
|
||||
await fs.writeFile(path.join(providerAssetDir, "google.png"), "google-asset", "utf8");
|
||||
await fs.writeFile(path.join(providerAssetDir, "x.png"), "x-asset", "utf8");
|
||||
await fs.writeFile(path.join(srcDir, "granola.png"), "legacy-granola-asset", "utf8");
|
||||
|
||||
await copyA2uiAssets({ srcDir, outDir });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(outDir, "assets", "providers", "google.png"), "utf8"),
|
||||
).resolves.toBe("google-asset");
|
||||
await expect(
|
||||
fs.readFile(path.join(outDir, "assets", "providers", "x.png"), "utf8"),
|
||||
).resolves.toBe("x-asset");
|
||||
await expect(fs.readFile(path.join(outDir, "granola.png"), "utf8")).resolves.toBe(
|
||||
"legacy-granola-asset",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
extensions/canvas/src/host/a2ui/assets/providers/google.png
Normal file
BIN
extensions/canvas/src/host/a2ui/assets/providers/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
extensions/canvas/src/host/a2ui/assets/providers/x.png
Normal file
BIN
extensions/canvas/src/host/a2ui/assets/providers/x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
extensions/canvas/src/host/a2ui/granola.png
Normal file
BIN
extensions/canvas/src/host/a2ui/granola.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -29,6 +29,7 @@ type CapturedResponse = {
|
||||
status: number;
|
||||
headers: Record<string, number | string | string[]>;
|
||||
body: string;
|
||||
bodyBytes: Buffer;
|
||||
};
|
||||
|
||||
type HttpRequestHandler = (
|
||||
@@ -73,6 +74,7 @@ async function captureHttpResponse(
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
bodyBytes: Buffer.alloc(0),
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
@@ -84,7 +86,8 @@ async function captureHttpResponse(
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
response.status = this.statusCode;
|
||||
response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? "");
|
||||
response.bodyBytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? "");
|
||||
response.body = response.bodyBytes.toString("utf8");
|
||||
return this;
|
||||
},
|
||||
};
|
||||
@@ -409,6 +412,19 @@ describe("canvas host", () => {
|
||||
const js = bundleRes.body;
|
||||
expect(bundleRes.status).toBe(200);
|
||||
expect(js).toContain("openclawA2UI");
|
||||
const expectedPngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
for (const assetPath of [
|
||||
"assets/providers/google.png",
|
||||
"assets/providers/x.png",
|
||||
"granola.png",
|
||||
]) {
|
||||
const assetRes = await captureA2uiResponse(`${A2UI_PATH}/${assetPath}`);
|
||||
expect(assetRes.status).toBe(200);
|
||||
expect(assetRes.headers["content-type"]).toBe("image/png");
|
||||
expect(assetRes.bodyBytes.subarray(0, expectedPngSignature.length)).toEqual(
|
||||
expectedPngSignature,
|
||||
);
|
||||
}
|
||||
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
|
||||
expect(traversalRes.status).toBe(404);
|
||||
expect(traversalRes.body).toBe("not found");
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string",
|
||||
"enum": ["openai-curated", "openai-bundled", "openai-primary-runtime"]
|
||||
"enum": ["openai-curated"]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
|
||||
@@ -7,7 +7,11 @@ import { startCodexAttemptThread } from "./attempt-startup.js";
|
||||
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { clearSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { createClientHarness, createCodexTestModel } from "./test-support.js";
|
||||
|
||||
type ClientHarness = ReturnType<typeof createClientHarness>;
|
||||
@@ -44,6 +48,8 @@ const bundleMcpThreadConfig = {
|
||||
fingerprint: undefined,
|
||||
} satisfies CodexBundleMcpThreadConfig;
|
||||
|
||||
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: string }> {
|
||||
return writes.map((write) => JSON.parse(write) as { id?: number; method?: string });
|
||||
}
|
||||
@@ -51,14 +57,24 @@ function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: st
|
||||
function startThreadWithHarness(
|
||||
startupTimeoutMs: number,
|
||||
signal = new AbortController().signal,
|
||||
overrides?: { pluginConfig?: CodexPluginConfig },
|
||||
overrides?: {
|
||||
pluginConfig?: CodexPluginConfig;
|
||||
attemptClientFactory?: (
|
||||
harness: ClientHarness,
|
||||
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
|
||||
harness?: ClientHarness;
|
||||
skipStartSpy?: boolean;
|
||||
},
|
||||
) {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const harness = overrides?.harness ?? createClientHarness();
|
||||
if (!overrides?.skipStartSpy) {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
}
|
||||
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
|
||||
|
||||
const run = startCodexAttemptThread({
|
||||
attemptClientFactory: defaultLeasedCodexAppServerClientFactory,
|
||||
attemptClientFactory:
|
||||
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
|
||||
pluginConfig: effectivePluginConfig,
|
||||
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
|
||||
@@ -91,7 +107,7 @@ function startThreadWithHarness(
|
||||
async function answerInitialize(harness: ClientHarness): Promise<void> {
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1), {
|
||||
interval: 1,
|
||||
timeout: 5_000,
|
||||
timeout: HARNESS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.125.0 (macOS; test)" } });
|
||||
@@ -106,7 +122,7 @@ async function waitForRequest(
|
||||
expect(readHarnessMessages(harness.writes).some((write) => write.method === method)).toBe(
|
||||
true,
|
||||
),
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
{ interval: 1, timeout: HARNESS_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
const request = readHarnessMessages(harness.writes).find((write) => write.method === method);
|
||||
if (!request) {
|
||||
@@ -147,8 +163,50 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("retires a failed startup client after another active lease releases", async () => {
|
||||
const retained = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(retained.client)
|
||||
.mockReturnValueOnce(replacement.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
retained.send({
|
||||
id: threadStart.id,
|
||||
error: { code: -32000, message: "401 authentication_error: Invalid bearer token" },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
|
||||
const replacementLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(replacement);
|
||||
await expect(replacementLease).resolves.toBe(replacement.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
|
||||
const { harness, run } = startThreadWithHarness(200);
|
||||
const { harness, run } = startThreadWithHarness(2_000);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
@@ -166,9 +224,99 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
|
||||
const retained = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
|
||||
await rejected;
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
});
|
||||
|
||||
it("closes the shared app-server when startup times out during initialize", async () => {
|
||||
const { harness, run } = startThreadWithHarness(2_000);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
const initialize = await waitForRequest(harness, "initialize");
|
||||
expect(initialize.id).toBeDefined();
|
||||
|
||||
const error = await runError;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("codex app-server startup timed out");
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a startup client that arrives after startup timeout", async () => {
|
||||
let observedFactoryOptions:
|
||||
| {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
let resolveFactoryDone: () => void = () => undefined;
|
||||
const factoryDone = new Promise<void>((resolve) => {
|
||||
resolveFactoryDone = resolve;
|
||||
});
|
||||
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
attemptClientFactory:
|
||||
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
|
||||
try {
|
||||
observedFactoryOptions = options;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
options?.onStartedClient?.(factoryHarness.client);
|
||||
return factoryHarness.client;
|
||||
} finally {
|
||||
resolveFactoryDone();
|
||||
}
|
||||
},
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
await rejected;
|
||||
await factoryDone;
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
|
||||
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
|
||||
const abortController = new AbortController();
|
||||
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
|
||||
const { harness, run } = startThreadWithHarness(30_000, abortController.signal);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
|
||||
@@ -44,8 +44,10 @@ import {
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
} from "./shared-client.js";
|
||||
import {
|
||||
startOrResumeThread,
|
||||
@@ -102,13 +104,23 @@ export async function startCodexAttemptThread(params: {
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
|
||||
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
|
||||
let startupAbandoned = false;
|
||||
const startupAbandonController = new AbortController();
|
||||
const abandonStartupAcquire = () => startupAbandonController.abort();
|
||||
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
|
||||
try {
|
||||
const startupResult = await withCodexStartupTimeout({
|
||||
timeoutMs: params.startupTimeoutMs,
|
||||
signal: params.signal,
|
||||
onTimeout: async () => {
|
||||
startupAbandoned = true;
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -172,25 +184,48 @@ export async function startCodexAttemptThread(params: {
|
||||
let attemptedClient: CodexAppServerClient | undefined;
|
||||
const startupAttempt = async () => {
|
||||
let startupClientLease: (() => void) | undefined;
|
||||
let startupClient: CodexAppServerClient | undefined;
|
||||
let startupAttemptError: unknown;
|
||||
let startupAttemptSucceeded = false;
|
||||
try {
|
||||
const startupClient = await params.attemptClientFactory(
|
||||
startupClient = await params.attemptClientFactory(
|
||||
params.appServer.start,
|
||||
params.startupAuthProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{
|
||||
onStartedClient: (client) => {
|
||||
startupClientForAbandonedRequestCleanup = client;
|
||||
if (startupAbandoned || startupAbandonController.signal.aborted) {
|
||||
void closeAbandonedStartupClient(client);
|
||||
}
|
||||
},
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
},
|
||||
);
|
||||
const activeStartupClient = startupClient;
|
||||
let startupClientLeaseReleased = false;
|
||||
startupClientLease = () => {
|
||||
releaseLeasedSharedCodexAppServerClient(startupClient);
|
||||
if (startupClientLeaseReleased) {
|
||||
return;
|
||||
}
|
||||
startupClientLeaseReleased = true;
|
||||
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
|
||||
};
|
||||
releaseSharedClientLease = startupClientLease;
|
||||
attemptedClient = startupClient;
|
||||
startupClientForAbandonedRequestCleanup = startupClient;
|
||||
attemptedClient = activeStartupClient;
|
||||
startupClientForAbandonedRequestCleanup = activeStartupClient;
|
||||
if (startupAbandoned) {
|
||||
throw new Error("codex app-server startup timed out");
|
||||
}
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
await ensureCodexComputerUse({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
pluginConfig: params.pluginConfig,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
});
|
||||
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
|
||||
let startupSandboxEnvironmentAcquired = false;
|
||||
@@ -208,15 +243,15 @@ export async function startCodexAttemptThread(params: {
|
||||
sandboxExecServerEnabled: params.sandboxExecServerEnabled,
|
||||
})
|
||||
? await ensureCodexSandboxExecServerEnvironment({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
sandbox: params.sandbox ?? null,
|
||||
appServerStartOptions: params.appServer.start,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
})
|
||||
: undefined;
|
||||
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
|
||||
if (params.signal.aborted) {
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
@@ -246,9 +281,9 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupSandboxPolicy = startupSandboxEnvironment
|
||||
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
|
||||
: undefined;
|
||||
const buildThreadLifecycleParams = () =>
|
||||
const buildThreadLifecycleParams = (signal: AbortSignal) =>
|
||||
({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
params: params.buildAttemptParams(),
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
@@ -266,7 +301,7 @@ export async function startCodexAttemptThread(params: {
|
||||
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
contextEngineProjection: params.contextEngineProjection,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
pluginThreadConfig: pluginThreadConfigRequired
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -276,9 +311,9 @@ export async function startCodexAttemptThread(params: {
|
||||
buildCodexPluginThreadConfig({
|
||||
pluginConfig: pluginThreadConfigPluginConfig,
|
||||
request: (method, requestParams) =>
|
||||
startupClient.request(method, requestParams, {
|
||||
activeStartupClient.request(method, requestParams, {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
@@ -287,22 +322,24 @@ export async function startCodexAttemptThread(params: {
|
||||
: undefined,
|
||||
}) satisfies Parameters<typeof startOrResumeThread>[0];
|
||||
try {
|
||||
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
|
||||
if (params.signal.aborted) {
|
||||
const startupThread = await startOrResumeThread(
|
||||
buildThreadLifecycleParams(startupAbandonController.signal),
|
||||
);
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
startupSandboxEnvironmentAcquired = false;
|
||||
startupAttemptSucceeded = true;
|
||||
return {
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
thread: startupThread,
|
||||
sandboxEnvironment: startupSandboxEnvironment,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
executionCwd: startupExecutionCwd,
|
||||
sandboxPolicy: startupSandboxPolicy,
|
||||
restartContextEngineCodexThread: () =>
|
||||
startOrResumeThread(buildThreadLifecycleParams()),
|
||||
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
|
||||
};
|
||||
} catch (error) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
@@ -312,12 +349,32 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseStartupResourcesOnTimeout = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
startupAttemptError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (!startupAttemptSucceeded) {
|
||||
if (releaseSharedClientLease === startupClientLease) {
|
||||
releaseSharedClientLease = undefined;
|
||||
}
|
||||
startupClientLease?.();
|
||||
if (startupAbandoned || params.signal.aborted) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await closeAbandonedStartupClient(startupClient);
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error: startupAttemptError,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await evictFailedStartupClient(startupClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -375,26 +432,115 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseSharedClientLease,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
params.signal.aborted ||
|
||||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(error) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
params.signal.removeEventListener("abort", abandonStartupAcquire);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAbandonedStartupClient(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
|
||||
const closeable = client as {
|
||||
close?: CodexAppServerClient["close"];
|
||||
closeAndWait?: CodexAppServerClient["closeAndWait"];
|
||||
};
|
||||
if (typeof closeable.closeAndWait === "function") {
|
||||
await closeable.closeAndWait();
|
||||
return;
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted" ||
|
||||
error.message.endsWith(" timed out"))
|
||||
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export type CodexAppServerClientFactory = (
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: AuthProfileOrderConfig,
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
@@ -25,9 +29,17 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
@@ -35,7 +47,15 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getLeasedSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -408,9 +408,10 @@ describe("CodexAppServerClient", () => {
|
||||
// Start a pending request so we can verify it gets properly rejected.
|
||||
const pending = harness.client.request("test/method");
|
||||
|
||||
// Simulate the child process closing its pipe — a write to the now-dead
|
||||
// stdin emits an asynchronous EPIPE error on the stream.
|
||||
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
// Simulate the child process closing its pipe: stdin emits an asynchronous
|
||||
// EPIPE error before the transport observes a process exit.
|
||||
const pipeError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
harness.process.stdin.emit("error", pipeError);
|
||||
|
||||
// The pending request must be rejected with the pipe error rather than
|
||||
// an unhandled exception tearing down the gateway.
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
defaultCodexAppServerClientFactory,
|
||||
defaultLeasedCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
|
||||
@@ -177,7 +178,8 @@ async function compactCodexNativeThread(
|
||||
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
|
||||
}
|
||||
|
||||
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
appServer.start,
|
||||
requestedAuthProfileId ?? binding.authProfileId,
|
||||
@@ -211,6 +213,10 @@ async function compactCodexNativeThread(
|
||||
compacted: false,
|
||||
reason: formatCompactionError(error),
|
||||
};
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
backend: "codex-app-server",
|
||||
|
||||
@@ -653,59 +653,6 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("accepts native plugin identities from every first-party OpenAI marketplace", () => {
|
||||
// OpenAI ships first-party Codex plugins across three marketplaces: the local
|
||||
// openai-bundled marketplace shipped with Codex.app (chrome, browser, computer-use,
|
||||
// latex-tectonic), the remote openai-curated marketplace, and the
|
||||
// openai-primary-runtime marketplace owned by the Codex primary runtime
|
||||
// (documents, spreadsheets, presentations). All three should resolve.
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
chrome: {
|
||||
marketplaceName: "openai-bundled",
|
||||
pluginName: "chrome",
|
||||
},
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
documents: {
|
||||
marketplaceName: "openai-primary-runtime",
|
||||
pluginName: "documents",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.codexPlugins?.enabled).toBe(true);
|
||||
const policy = resolveCodexPluginsPolicy(config);
|
||||
expect(policy.pluginPolicies).toEqual([
|
||||
{
|
||||
configKey: "chrome",
|
||||
marketplaceName: "openai-bundled",
|
||||
pluginName: "chrome",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
{
|
||||
configKey: "documents",
|
||||
marketplaceName: "openai-primary-runtime",
|
||||
pluginName: "documents",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
{
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats configured and environment commands as explicit overrides", () => {
|
||||
expectFields(
|
||||
resolveRuntimeForTest({
|
||||
|
||||
@@ -60,30 +60,7 @@ type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "
|
||||
export type CodexDynamicToolsLoading = "searchable" | "direct";
|
||||
export type CodexPluginDestructivePolicy = boolean;
|
||||
|
||||
// OpenAI ships first-party Codex plugins across three marketplaces:
|
||||
// - openai-curated: remote curated marketplace, fetched via `codex plugin marketplace add`
|
||||
// - openai-bundled: local marketplace that ships with Codex.app and the Codex CLI
|
||||
// (browser, chrome, computer-use, latex-tectonic)
|
||||
// - openai-primary-runtime: marketplace owned by the Codex primary runtime
|
||||
// (documents, spreadsheets, presentations)
|
||||
// All three are owned by OpenAI. Allow activating plugins from any of them.
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAMES = [
|
||||
"openai-curated",
|
||||
"openai-bundled",
|
||||
"openai-primary-runtime",
|
||||
] as const;
|
||||
export type CodexPluginsMarketplaceName = (typeof CODEX_PLUGINS_MARKETPLACE_NAMES)[number];
|
||||
|
||||
// Back-compat constant for callers that still reference the curated marketplace by name.
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME: CodexPluginsMarketplaceName = "openai-curated";
|
||||
|
||||
export function isCodexPluginsMarketplaceName(
|
||||
name: string | undefined,
|
||||
): name is CodexPluginsMarketplaceName {
|
||||
return (
|
||||
name !== undefined && (CODEX_PLUGINS_MARKETPLACE_NAMES as readonly string[]).includes(name)
|
||||
);
|
||||
}
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
|
||||
|
||||
export type CodexComputerUseConfig = {
|
||||
enabled?: boolean;
|
||||
@@ -126,7 +103,7 @@ export type CodexAppServerExperimentalConfig = {
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: CodexPluginsMarketplaceName;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
allowDestructiveActions: CodexPluginDestructivePolicy;
|
||||
@@ -278,7 +255,7 @@ const codexAppServerExperimentalSchema = z
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
marketplaceName: z.enum(CODEX_PLUGINS_MARKETPLACE_NAMES).optional(),
|
||||
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
|
||||
pluginName: z.string().trim().min(1).optional(),
|
||||
allow_destructive_actions: z.boolean().optional(),
|
||||
})
|
||||
@@ -388,13 +365,13 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
|
||||
const allowDestructiveActions = config?.allow_destructive_actions ?? true;
|
||||
const pluginPolicies = Object.entries(config?.plugins ?? {})
|
||||
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
|
||||
if (!isCodexPluginsMarketplaceName(entry.marketplaceName) || !entry.pluginName) {
|
||||
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
configKey,
|
||||
marketplaceName: entry.marketplaceName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: entry.pluginName,
|
||||
enabled: enabled && entry.enabled !== false,
|
||||
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
@@ -223,6 +226,38 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
});
|
||||
|
||||
it("quarantines non-object plugin schemas before Codex-specific filtering", async () => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const brokenTool = {
|
||||
...createRuntimeDynamicTool("dofbot_move_angles"),
|
||||
parameters: { type: "array", items: { type: "number" } },
|
||||
};
|
||||
setOpenClawCodingToolsFactoryForTests(() => [brokenTool, messageTool]);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"codex app-server quarantined 1 unsupported runtime tool schema before dynamic tool registration",
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
diagnostics: [
|
||||
{
|
||||
index: 0,
|
||||
tool: "dofbot_move_angles",
|
||||
violations: ['dofbot_move_angles.parameters.type must be "object"'],
|
||||
violationCount: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("limits Codex memory flush runs to managed read and write tools", async () => {
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
|
||||
@@ -22,59 +22,6 @@ describe("Codex plugin activation", () => {
|
||||
expect((params as Record<string, unknown> | undefined)?.[key]).toBe(expected);
|
||||
}
|
||||
|
||||
it("activates plugins from every first-party OpenAI marketplace", async () => {
|
||||
// chrome ships in openai-bundled (with Codex.app), documents ships in
|
||||
// openai-primary-runtime (Codex primary runtime). Both should activate the
|
||||
// same way openai-curated plugins do.
|
||||
for (const { plugin, marketplace } of [
|
||||
{ plugin: "chrome", marketplace: "openai-bundled" as const },
|
||||
{ plugin: "documents", marketplace: "openai-primary-runtime" as const },
|
||||
]) {
|
||||
const calls: string[] = [];
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: identity(plugin, marketplace),
|
||||
request: async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "plugin/list") {
|
||||
return pluginListFor(marketplace, [
|
||||
pluginSummary(plugin, { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expectActivationResult(result, {
|
||||
ok: true,
|
||||
reason: "already_active",
|
||||
installAttempted: false,
|
||||
});
|
||||
expect(result.marketplace?.name).toBe(marketplace);
|
||||
expect(calls).toEqual(["plugin/list"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects activation requests for marketplaces outside the openai allowlist", async () => {
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: {
|
||||
configKey: "rogue",
|
||||
marketplaceName: "third-party" as never,
|
||||
pluginName: "rogue",
|
||||
enabled: true,
|
||||
allowDestructiveActions: false,
|
||||
},
|
||||
request: async () => {
|
||||
throw new Error("plugin/list should not be reached when marketplace is rejected");
|
||||
},
|
||||
});
|
||||
|
||||
expectActivationResult(result, {
|
||||
ok: false,
|
||||
reason: "marketplace_missing",
|
||||
installAttempted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips plugin/install when the migrated plugin is already active", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await ensureCodexPluginActivation({
|
||||
@@ -348,13 +295,10 @@ describe("Codex plugin activation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function identity(
|
||||
pluginName: string,
|
||||
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"] = CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
): ResolvedCodexPluginPolicy {
|
||||
function identity(pluginName: string): ResolvedCodexPluginPolicy {
|
||||
return {
|
||||
configKey: pluginName,
|
||||
marketplaceName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName,
|
||||
enabled: true,
|
||||
allowDestructiveActions: false,
|
||||
@@ -376,24 +320,6 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function pluginListFor(
|
||||
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"],
|
||||
plugins: v2.PluginSummary[],
|
||||
): v2.PluginListResponse {
|
||||
return {
|
||||
marketplaces: [
|
||||
{
|
||||
name: marketplaceName,
|
||||
path: `/marketplaces/${marketplaceName}`,
|
||||
interface: null,
|
||||
plugins,
|
||||
},
|
||||
],
|
||||
marketplaceLoadErrors: [],
|
||||
featuredPluginIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAMES,
|
||||
isCodexPluginsMarketplaceName,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
} from "./config.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
|
||||
import {
|
||||
findOpenAiCuratedPluginSummary,
|
||||
pluginReadParams,
|
||||
@@ -52,32 +48,27 @@ export type CodexPluginRuntimeRefreshResult = {
|
||||
export async function ensureCodexPluginActivation(
|
||||
params: EnsureCodexPluginActivationParams,
|
||||
): Promise<CodexPluginActivationResult> {
|
||||
if (!isCodexPluginsMarketplaceName(params.identity.marketplaceName)) {
|
||||
if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) {
|
||||
return activationFailure(params.identity, "marketplace_missing", {
|
||||
message:
|
||||
"Only " + CODEX_PLUGINS_MARKETPLACE_NAMES.join(" or ") + " plugins can be activated.",
|
||||
message: "Only openai-curated plugins can be activated.",
|
||||
});
|
||||
}
|
||||
|
||||
const listed = (await params.request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
||||
const resolved = findOpenAiCuratedPluginSummary(
|
||||
listed,
|
||||
params.identity.pluginName,
|
||||
params.identity.marketplaceName,
|
||||
);
|
||||
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
|
||||
if (!resolved) {
|
||||
const hasMarketplace = listed.marketplaces.some(
|
||||
(marketplace) => marketplace.name === params.identity.marketplaceName,
|
||||
const hasCuratedMarketplace = listed.marketplaces.some(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
);
|
||||
if (!hasMarketplace) {
|
||||
if (!hasCuratedMarketplace) {
|
||||
return activationFailure(params.identity, "marketplace_missing", {
|
||||
message: `Codex marketplace ${params.identity.marketplaceName} was not found.`,
|
||||
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
|
||||
});
|
||||
}
|
||||
return activationFailure(params.identity, "plugin_missing", {
|
||||
message: `${params.identity.pluginName} was not found in ${params.identity.marketplaceName}.`,
|
||||
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import type {
|
||||
} from "./app-inventory-cache.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
CODEX_PLUGINS_MARKETPLACE_NAMES,
|
||||
isCodexPluginsMarketplaceName,
|
||||
resolveCodexPluginsPolicy,
|
||||
type CodexPluginsMarketplaceName,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
type ResolvedCodexPluginsPolicy,
|
||||
} from "./config.js";
|
||||
@@ -18,7 +15,7 @@ import type { v2 } from "./protocol.js";
|
||||
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
export type CodexPluginMarketplaceRef = {
|
||||
name: CodexPluginsMarketplaceName;
|
||||
name: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
path?: string;
|
||||
remoteMarketplaceName?: string;
|
||||
};
|
||||
@@ -60,6 +57,7 @@ export type CodexPluginInventoryRecord = {
|
||||
|
||||
export type CodexPluginInventory = {
|
||||
policy: ResolvedCodexPluginsPolicy;
|
||||
marketplace?: CodexPluginMarketplaceRef;
|
||||
records: CodexPluginInventoryRecord[];
|
||||
diagnostics: CodexPluginInventoryDiagnostic[];
|
||||
appInventory?: CodexAppInventoryCacheRead;
|
||||
@@ -97,14 +95,25 @@ export async function readCodexPluginInventory(
|
||||
const listed = (await params.request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
||||
// Index the supported marketplaces (curated + bundled) by name so each plugin
|
||||
// policy is matched to the marketplace its config actually points at.
|
||||
const marketplaceByName = new Map<CodexPluginsMarketplaceName, v2.PluginMarketplaceEntry>();
|
||||
for (const marketplace of listed.marketplaces) {
|
||||
if (isCodexPluginsMarketplaceName(marketplace.name)) {
|
||||
marketplaceByName.set(marketplace.name, marketplace);
|
||||
}
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return {
|
||||
policy,
|
||||
records: [],
|
||||
diagnostics: policy.pluginPolicies
|
||||
.filter((pluginPolicy) => pluginPolicy.enabled)
|
||||
.map((pluginPolicy) => ({
|
||||
code: "marketplace_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
|
||||
})),
|
||||
...(appInventory ? { appInventory } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const marketplace = marketplaceRef(marketplaceEntry);
|
||||
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
|
||||
const records: CodexPluginInventoryRecord[] = [];
|
||||
if (appInventory?.state === "missing") {
|
||||
@@ -123,22 +132,12 @@ export async function readCodexPluginInventory(
|
||||
if (!pluginPolicy.enabled) {
|
||||
continue;
|
||||
}
|
||||
const marketplaceEntry = marketplaceByName.get(pluginPolicy.marketplaceName);
|
||||
if (!marketplaceEntry) {
|
||||
diagnostics.push({
|
||||
code: "marketplace_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `Codex marketplace ${pluginPolicy.marketplaceName} was not found.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const marketplace = marketplaceRef(marketplaceEntry);
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName);
|
||||
if (!summary) {
|
||||
diagnostics.push({
|
||||
code: "plugin_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `${pluginPolicy.pluginName} was not found in ${pluginPolicy.marketplaceName}.`,
|
||||
message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -188,6 +187,7 @@ export async function readCodexPluginInventory(
|
||||
|
||||
const inventory = {
|
||||
policy,
|
||||
marketplace,
|
||||
records,
|
||||
diagnostics,
|
||||
...(appInventory ? { appInventory } : {}),
|
||||
@@ -198,32 +198,15 @@ export async function readCodexPluginInventory(
|
||||
export function findOpenAiCuratedPluginSummary(
|
||||
listed: v2.PluginListResponse,
|
||||
pluginName: string,
|
||||
marketplaceName?: CodexPluginsMarketplaceName,
|
||||
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
|
||||
if (marketplaceName) {
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === marketplaceName,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return undefined;
|
||||
}
|
||||
// No marketplace hint: search every supported marketplace and return the first hit.
|
||||
for (const allowedName of CODEX_PLUGINS_MARKETPLACE_NAMES) {
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === allowedName,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
continue;
|
||||
}
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
if (summary) {
|
||||
return { marketplace: marketplaceRef(marketplaceEntry), summary };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
|
||||
}
|
||||
|
||||
export function pluginReadParams(
|
||||
@@ -366,12 +349,8 @@ function pluginNameFromPluginId(pluginId: string, marketplaceName: string): stri
|
||||
}
|
||||
|
||||
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
|
||||
// marketplace.name is validated at every call site via isCodexPluginsMarketplaceName.
|
||||
const name = isCodexPluginsMarketplaceName(marketplace.name)
|
||||
? marketplace.name
|
||||
: CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
return {
|
||||
name,
|
||||
name: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
...(marketplace.path ? { path: marketplace.path } : {}),
|
||||
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
|
||||
};
|
||||
|
||||
@@ -107,36 +107,6 @@ describe("codex app-server session binding", () => {
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips plugin app policy context for openai-bundled marketplace plugins", async () => {
|
||||
// The chrome plugin lives in openai-bundled (ships with Codex.app), so
|
||||
// its policy must persist across reads/writes the same way curated entries do.
|
||||
const sessionFile = path.join(tempDir, "session-bundled.json");
|
||||
const pluginAppPolicyContext = {
|
||||
fingerprint: "plugin-policy-bundled-1",
|
||||
apps: {
|
||||
"chrome-app": {
|
||||
configKey: "chrome",
|
||||
marketplaceName: "openai-bundled" as const,
|
||||
pluginName: "chrome",
|
||||
allowDestructiveActions: true,
|
||||
mcpServerNames: ["chrome"],
|
||||
},
|
||||
},
|
||||
pluginAppIds: {
|
||||
chrome: ["chrome-app"],
|
||||
},
|
||||
};
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bundled",
|
||||
cwd: tempDir,
|
||||
pluginAppPolicyContext,
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
|
||||
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
|
||||
});
|
||||
|
||||
it("round-trips context-engine binding metadata", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type AuthProfileStore,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
isCodexPluginsMarketplaceName,
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
normalizeCodexServiceTier,
|
||||
type CodexAppServerApprovalPolicy,
|
||||
type CodexAppServerSandboxMode,
|
||||
@@ -257,8 +257,7 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un
|
||||
if (
|
||||
"appId" in entry ||
|
||||
typeof entry.configKey !== "string" ||
|
||||
typeof entry.marketplaceName !== "string" ||
|
||||
!isCodexPluginsMarketplaceName(entry.marketplaceName) ||
|
||||
entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
|
||||
typeof entry.pluginName !== "string" ||
|
||||
typeof entry.allowDestructiveActions !== "boolean" ||
|
||||
!Array.isArray(entry.mcpServerNames) ||
|
||||
|
||||
@@ -189,6 +189,28 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a pending shared app-server alive when another acquire still owns startup", async () => {
|
||||
const harness = createClientHarness();
|
||||
const abandonController = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const abandonedAcquire = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
abandonSignal: abandonController.signal,
|
||||
});
|
||||
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
abandonController.abort();
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(abandonedAcquire).resolves.toBe(harness.client);
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not wait for isolated initialize after a timeout closes the client", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
@@ -18,6 +18,7 @@ type SharedCodexAppServerClientEntry = {
|
||||
client?: CodexAppServerClient;
|
||||
promise?: Promise<CodexAppServerClient>;
|
||||
activeLeases: number;
|
||||
pendingAcquires: number;
|
||||
closeWhenIdle: boolean;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
|
||||
for (const entry of clients.values()) {
|
||||
entry.activeLeases ??= 0;
|
||||
entry.pendingAcquires ??= 0;
|
||||
entry.closeWhenIdle ??= false;
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = {
|
||||
@@ -66,6 +68,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
client: legacyState.client,
|
||||
promise: legacyState.promise,
|
||||
activeLeases: 0,
|
||||
pendingAcquires: 0,
|
||||
closeWhenIdle: false,
|
||||
});
|
||||
legacyState.client?.addCloseHandler((closedClient) =>
|
||||
@@ -102,6 +105,8 @@ type CodexAppServerClientOptions = {
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ResolvedCodexAppServerClientStartContext = {
|
||||
@@ -194,11 +199,27 @@ async function acquireSharedCodexAppServerClient(
|
||||
});
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const entry = getOrCreateSharedClientEntry(state, key);
|
||||
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
|
||||
let cleanupAbandonSignal: (() => void) | undefined;
|
||||
if (options?.abandonSignal) {
|
||||
const abandon = () => {
|
||||
// Release this acquire before cleanup checks ownership; only other
|
||||
// pending callers should keep the startup client alive.
|
||||
releasePendingAcquire();
|
||||
closeSharedClientEntryIfUnclaimed(key, entry);
|
||||
};
|
||||
options.abandonSignal.addEventListener("abort", abandon, { once: true });
|
||||
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
|
||||
if (options.abandonSignal.aborted) {
|
||||
abandon();
|
||||
}
|
||||
}
|
||||
const sharedPromise =
|
||||
entry.promise ??
|
||||
(entry.promise = (async () => {
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
entry.client = client;
|
||||
options?.onStartedClient?.(client);
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
try {
|
||||
@@ -233,6 +254,9 @@ async function acquireSharedCodexAppServerClient(
|
||||
clearSharedClientEntry(key, currentEntry);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanupAbandonSignal?.();
|
||||
releasePendingAcquire();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +410,7 @@ function getOrCreateSharedClientEntry(
|
||||
): SharedCodexAppServerClientEntry {
|
||||
let entry = state.clients.get(key);
|
||||
if (!entry) {
|
||||
entry = { activeLeases: 0, closeWhenIdle: false };
|
||||
entry = { activeLeases: 0, pendingAcquires: 0, closeWhenIdle: false };
|
||||
state.clients.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
@@ -409,6 +433,39 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
|
||||
if (!client) {
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
return {
|
||||
found: true,
|
||||
closed: closeSharedClientEntryIfUnclaimed(key, entry),
|
||||
activeLeases: entry.activeLeases,
|
||||
pendingAcquires: entry.pendingAcquires,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
|
||||
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.pendingAcquires += 1;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
|
||||
closeRetiredSharedClientEntryIfIdle(entry);
|
||||
};
|
||||
}
|
||||
|
||||
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.activeLeases += 1;
|
||||
@@ -423,7 +480,12 @@ function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () =>
|
||||
}
|
||||
|
||||
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
|
||||
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
|
||||
if (
|
||||
!entry.closeWhenIdle ||
|
||||
entry.activeLeases > 0 ||
|
||||
entry.pendingAcquires > 0 ||
|
||||
!entry.client
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const client = entry.client;
|
||||
@@ -433,6 +495,22 @@ function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEn
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeSharedClientEntryIfUnclaimed(
|
||||
key: string,
|
||||
entry: SharedCodexAppServerClientEntry,
|
||||
): boolean {
|
||||
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
|
||||
return false;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
if (state.clients.get(key) !== entry) {
|
||||
return false;
|
||||
}
|
||||
state.clients.delete(key);
|
||||
entry.client?.close();
|
||||
return Boolean(entry.client);
|
||||
}
|
||||
|
||||
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
||||
@@ -22,6 +22,15 @@ export function createClientHarness() {
|
||||
const stdout = new PassThrough();
|
||||
const writes: string[] = [];
|
||||
let stdinDestroyed = false;
|
||||
let exitEmitted = false;
|
||||
let emitProcessExit: () => void = () => undefined;
|
||||
type HarnessProcess = EventEmitter & {
|
||||
stdin: Writable;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
killed: boolean;
|
||||
kill: (signal?: NodeJS.Signals) => unknown;
|
||||
};
|
||||
const stdin = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
writes.push(chunk.toString());
|
||||
@@ -31,17 +40,27 @@ export function createClientHarness() {
|
||||
const destroyStdin = stdin.destroy.bind(stdin);
|
||||
stdin.destroy = ((error?: Error) => {
|
||||
stdinDestroyed = true;
|
||||
return destroyStdin(error);
|
||||
const result = destroyStdin(error);
|
||||
if (!exitEmitted) {
|
||||
exitEmitted = true;
|
||||
// Let stdin surface pipe errors before the harness emits the fake child exit.
|
||||
// Otherwise close-reason tests can race EPIPE against a synthetic clean exit.
|
||||
setImmediate(emitProcessExit);
|
||||
}
|
||||
return result;
|
||||
}) as typeof stdin.destroy;
|
||||
const process = Object.assign(new EventEmitter(), {
|
||||
const process: HarnessProcess = Object.assign(new EventEmitter(), {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new PassThrough(),
|
||||
killed: false,
|
||||
kill: vi.fn(() => {
|
||||
kill: vi.fn((_signal?: NodeJS.Signals) => {
|
||||
process.killed = true;
|
||||
}),
|
||||
});
|
||||
emitProcessExit = () => {
|
||||
process.emit("exit", 0, null);
|
||||
};
|
||||
const client = CodexAppServerClient.fromTransportForTests(process);
|
||||
return {
|
||||
client,
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../app-server/auth-bridge.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
isCodexPluginsMarketplaceName,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
@@ -355,13 +354,12 @@ function hasOpenAiCuratedMarketplace(response: unknown): boolean {
|
||||
const marketplaces = (response as { marketplaces?: unknown }).marketplaces;
|
||||
return (
|
||||
Array.isArray(marketplaces) &&
|
||||
marketplaces.some((marketplace) => {
|
||||
if (!marketplace || typeof marketplace !== "object") {
|
||||
return false;
|
||||
}
|
||||
const name = (marketplace as { name?: unknown }).name;
|
||||
return name === CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
})
|
||||
marketplaces.some(
|
||||
(marketplace) =>
|
||||
marketplace &&
|
||||
typeof marketplace === "object" &&
|
||||
(marketplace as { name?: unknown }).name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -498,15 +496,14 @@ function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy |
|
||||
const pluginName = item.details?.pluginName;
|
||||
if (
|
||||
typeof configKey !== "string" ||
|
||||
typeof marketplaceName !== "string" ||
|
||||
!isCodexPluginsMarketplaceName(marketplaceName) ||
|
||||
marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
|
||||
typeof pluginName !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
configKey,
|
||||
marketplaceName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName,
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
|
||||
@@ -1462,7 +1462,7 @@ describe("buildCodexMigrationProvider", () => {
|
||||
if (method === "plugin/list" && isTarget) {
|
||||
targetPluginListCalls += 1;
|
||||
if (targetPluginListCalls === 1) {
|
||||
return pluginList([], "openai-bundled");
|
||||
return { marketplaces: [], marketplaceLoadErrors: [], featuredPluginIds: [] };
|
||||
}
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
@@ -2225,15 +2225,12 @@ function createConfigRuntime(
|
||||
} as unknown as MigrationProviderContext["runtime"];
|
||||
}
|
||||
|
||||
function pluginList(
|
||||
plugins: v2.PluginSummary[],
|
||||
marketplaceName = CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
): v2.PluginListResponse {
|
||||
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
|
||||
return {
|
||||
marketplaces: [
|
||||
{
|
||||
name: marketplaceName,
|
||||
path: `/marketplaces/${marketplaceName}`,
|
||||
name: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
path: "/marketplaces/openai-curated",
|
||||
interface: null,
|
||||
plugins,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
export {
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./src/directory-config.js";
|
||||
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
};
|
||||
|
||||
export const discordDirectoryContractPlugin = {
|
||||
id: "discord",
|
||||
directory: {
|
||||
listPeers: listDiscordDirectoryPeersFromConfig,
|
||||
listGroups: listDiscordDirectoryGroupsFromConfig,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
createChannelProgressDraftCompositor,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -79,86 +72,48 @@ export function createDiscordDraftPreviewController(params: {
|
||||
let draftText = "";
|
||||
let hasStreamedMessage = false;
|
||||
let finalizedViaPreviewMessage = false;
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
const previewToolProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
|
||||
const commentaryProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
|
||||
const suppressDefaultToolProgressMessages =
|
||||
Boolean(draftStream) &&
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
|
||||
draftStreamActive: true,
|
||||
previewToolProgressEnabled,
|
||||
});
|
||||
let previewToolProgressSuppressed = false;
|
||||
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let reasoningProgressRawText = "";
|
||||
let lastReasoningProgressLine: string | undefined;
|
||||
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
|
||||
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }) => {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (!previewText || previewText === lastPartialText) {
|
||||
return;
|
||||
}
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream.flush();
|
||||
}
|
||||
};
|
||||
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: () => renderProgressDraft({ flush: true }),
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
entry: params.discordConfig,
|
||||
mode: discordStreamMode,
|
||||
active: Boolean(draftStream),
|
||||
seed: progressSeed,
|
||||
update: async (previewText, options) => {
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream?.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream?.flush();
|
||||
}
|
||||
},
|
||||
deleteCurrent: async () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
},
|
||||
isEmptyLine: isEmptyDiscordProgressLine,
|
||||
shouldStartNow: shouldStartDiscordProgressDraftNow,
|
||||
});
|
||||
|
||||
const clearProgressDraftLine = async (lineId: string) => {
|
||||
const nextLines = previewToolProgressLines.filter(
|
||||
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
|
||||
);
|
||||
if (nextLines.length === previewToolProgressLines.length) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
if (!progressDraftGate.hasStarted) {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (previewText) {
|
||||
await renderProgressDraft();
|
||||
return;
|
||||
}
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const resetProgressState = () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
draftChunker?.reset();
|
||||
previewToolProgressSuppressed = false;
|
||||
previewToolProgressLines = [];
|
||||
reasoningProgressRawText = "";
|
||||
lastReasoningProgressLine = undefined;
|
||||
progressDraft.reset();
|
||||
};
|
||||
|
||||
const forceNewMessageIfNeeded = () => {
|
||||
@@ -172,22 +127,23 @@ export function createDiscordDraftPreviewController(params: {
|
||||
return {
|
||||
draftStream,
|
||||
previewToolProgressEnabled,
|
||||
commentaryProgressEnabled,
|
||||
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
|
||||
suppressDefaultToolProgressMessages,
|
||||
get isProgressMode() {
|
||||
return discordStreamMode === "progress";
|
||||
},
|
||||
get hasProgressDraftStarted() {
|
||||
return progressDraftGate.hasStarted;
|
||||
return progressDraft.hasStarted;
|
||||
},
|
||||
get finalizedViaPreviewMessage() {
|
||||
return finalizedViaPreviewMessage;
|
||||
},
|
||||
markFinalReplyStarted() {
|
||||
finalReplyStarted = true;
|
||||
progressDraft.markFinalReplyStarted();
|
||||
},
|
||||
markFinalReplyDelivered() {
|
||||
finalReplyDelivered = true;
|
||||
progressDraft.markFinalReplyDelivered();
|
||||
},
|
||||
markPreviewFinalized() {
|
||||
finalizedViaPreviewMessage = true;
|
||||
@@ -197,149 +153,19 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
await progressDraftGate.startNow();
|
||||
await progressDraft.start();
|
||||
},
|
||||
async pushToolProgress(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
) {
|
||||
if (!draftStream) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options?.toolName !== undefined &&
|
||||
!isChannelProgressDraftWorkToolName(options.toolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEmptyDiscordProgressLine(line)) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeChannelProgressDraftLineIdentity(line);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const progressLine: string | ChannelProgressDraftLine =
|
||||
typeof line === "object" && line !== undefined ? line : normalized;
|
||||
if (discordStreamMode !== "progress") {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
|
||||
return;
|
||||
}
|
||||
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
if (nextLines === previewToolProgressLines) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(
|
||||
previewToolProgressLines,
|
||||
progressLine,
|
||||
{
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
},
|
||||
);
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
let progressActive;
|
||||
if (shouldStartDiscordProgressDraftNow(line)) {
|
||||
await progressDraftGate.startNow();
|
||||
progressActive = progressDraftGate.hasStarted;
|
||||
} else {
|
||||
progressActive = await progressDraftGate.noteWork();
|
||||
}
|
||||
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushToolProgress(line, options);
|
||||
},
|
||||
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !text) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
|
||||
snapshot: options?.snapshot === true,
|
||||
});
|
||||
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const displayLine = formatReasoningProgressDisplayLine(
|
||||
normalized,
|
||||
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
|
||||
);
|
||||
if (!displayLine) {
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const priorIndex =
|
||||
lastReasoningProgressLine === undefined
|
||||
? -1
|
||||
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
|
||||
if (priorIndex >= 0) {
|
||||
previewToolProgressLines = [...previewToolProgressLines];
|
||||
previewToolProgressLines[priorIndex] = displayLine;
|
||||
} else {
|
||||
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
|
||||
-resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
);
|
||||
}
|
||||
lastReasoningProgressLine = displayLine;
|
||||
}
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if (progressActive && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushReasoningProgress(text, options);
|
||||
},
|
||||
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
const itemId = options?.itemId?.trim();
|
||||
if (!text && !itemId) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeCommentaryProgressText(text ?? "");
|
||||
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
||||
if (!normalized) {
|
||||
if (lineId) {
|
||||
await clearProgressDraftLine(lineId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const line: ChannelProgressDraftLine = {
|
||||
id: lineId,
|
||||
kind: "item",
|
||||
text: normalized,
|
||||
label: "Commentary",
|
||||
prefix: false,
|
||||
};
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
await progressDraftGate.startNow();
|
||||
await renderProgressDraft();
|
||||
await progressDraft.pushCommentaryProgress(text, options);
|
||||
},
|
||||
resolvePreviewFinalText(text?: string) {
|
||||
if (typeof text !== "string") {
|
||||
@@ -390,8 +216,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (discordStreamMode === "progress") {
|
||||
return;
|
||||
}
|
||||
previewToolProgressSuppressed = true;
|
||||
previewToolProgressLines = [];
|
||||
progressDraft.suppress();
|
||||
hasStreamedMessage = true;
|
||||
if (discordStreamMode === "partial") {
|
||||
if (
|
||||
@@ -457,7 +282,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
},
|
||||
async cleanup() {
|
||||
try {
|
||||
progressDraftGate.cancel();
|
||||
progressDraft.cancel();
|
||||
if (!finalReplyDelivered) {
|
||||
await draftStream?.discardPending();
|
||||
}
|
||||
@@ -471,106 +296,6 @@ export function createDiscordDraftPreviewController(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressInput(text: string): string {
|
||||
const normalized = normalizeReasoningProgressLine(text);
|
||||
const italic = normalized.match(/^_(.*)_$/u);
|
||||
return (italic?.[1] ?? normalized).trim();
|
||||
}
|
||||
|
||||
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalizedText = normalizeReasoningProgressInput(text);
|
||||
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
|
||||
if (!formatted) {
|
||||
return "";
|
||||
}
|
||||
if (Array.from(formatted).length <= maxChars) {
|
||||
return formatted;
|
||||
}
|
||||
const italic = formatted.match(/^_(.*)_$/u);
|
||||
if (!italic) {
|
||||
return compactReasoningProgressDisplayLine(formatted, maxChars);
|
||||
}
|
||||
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
if (maxChars <= 1) {
|
||||
return "…";
|
||||
}
|
||||
const head = chars
|
||||
.slice(0, maxChars - 1)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
const boundary = head.search(/\s+\S*$/u);
|
||||
if (boundary > Math.floor(maxChars * 0.6)) {
|
||||
return `${head.slice(0, boundary).trimEnd()}…`;
|
||||
}
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSilentCommentaryProgressText(text: string): boolean {
|
||||
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
|
||||
return /^NO_REPLY$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
options?: { snapshot?: boolean },
|
||||
): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
const normalizedCurrent = normalizeReasoningProgressLine(current);
|
||||
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
|
||||
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
options?.snapshot === true ||
|
||||
isReasoningSnapshotText(incoming) ||
|
||||
normalizedIncoming.startsWith(normalizedCurrent)
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
|
||||
if (!line || typeof line === "string") {
|
||||
return false;
|
||||
|
||||
@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when no recovered reply is available", async () => {
|
||||
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
|
||||
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when the recovered reply fails to send", async () => {
|
||||
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
|
||||
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
|
||||
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [{ text: "delivery failed" }],
|
||||
});
|
||||
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mutating tool warning finals after successful-looking replies", async () => {
|
||||
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "Done." });
|
||||
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expectPreviewEditContent("Done.");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `write file (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payload delivery to Discord", async () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
import {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
|
||||
return !resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||
}
|
||||
|
||||
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
|
||||
type DiscordReplySkipReason =
|
||||
| "aborted before delivery"
|
||||
| "reasoning payload"
|
||||
| "internal-only payload";
|
||||
|
||||
export function formatDiscordReplySkip(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
@@ -621,7 +625,7 @@ async function processDiscordMessageInner(
|
||||
) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
// Surface so operators don't chase missing replies when an abort
|
||||
// drops a model-produced text payload (see PR for the incident).
|
||||
// drops a model-produced text payload.
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
|
||||
})
|
||||
: payload.text;
|
||||
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
|
||||
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
|
||||
kind: info.kind,
|
||||
});
|
||||
if (!deliverablePayload) {
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
reason: "internal-only payload",
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
const draftStream = draftPreview.draftStream;
|
||||
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
if (!reply.hasMedia && !payload.isError) {
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
if (!reply.hasMedia && !deliverablePayload.isError) {
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
}
|
||||
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
|
||||
draftStream &&
|
||||
isFinal &&
|
||||
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
|
||||
!payload.isError;
|
||||
!deliverablePayload.isError;
|
||||
if (shouldFinalizeDraftPreview) {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
const hasMedia = reply.hasMedia;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
|
||||
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
|
||||
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
|
||||
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
|
||||
const previewReplyToId = replyReference.peek();
|
||||
const hasExplicitReplyDirective =
|
||||
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
|
||||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
|
||||
(typeof previewSourceText === "string" &&
|
||||
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: info.kind,
|
||||
payload: effectivePayload,
|
||||
payload: deliverablePayload,
|
||||
adapter: defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: () => draftPreview.flush(),
|
||||
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
|
||||
(hasMedia && !ttsSupplement) ||
|
||||
typeof previewFinalText !== "string" ||
|
||||
hasExplicitReplyDirective ||
|
||||
payload.isError
|
||||
deliverablePayload.isError
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
|
||||
replyReference.markSent();
|
||||
},
|
||||
buildSupplementalPayload: () =>
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
|
||||
deliverSupplemental: async (supplementalPayload) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
return false;
|
||||
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
|
||||
const fallbackPayload =
|
||||
ttsSupplement &&
|
||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||
!effectivePayload.text?.trim()
|
||||
? { ...effectivePayload, text: ttsSupplement.spokenText }
|
||||
: effectivePayload;
|
||||
!deliverablePayload.text?.trim()
|
||||
? { ...deliverablePayload, text: ttsSupplement.spokenText }
|
||||
: deliverablePayload;
|
||||
const replyToId = replyReference.use();
|
||||
notifyFinalReplyStart();
|
||||
await deliverDiscordReply({
|
||||
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
|
||||
}
|
||||
await deliverDiscordReply({
|
||||
cfg,
|
||||
replies: [effectivePayload],
|
||||
replies: [deliverablePayload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
|
||||
kind: info.kind,
|
||||
});
|
||||
replyReference.markSent();
|
||||
if (isFinal && payload.isError !== true) {
|
||||
if (isFinal && deliverablePayload.isError !== true) {
|
||||
markUserFacingFinalDelivered();
|
||||
}
|
||||
return { visibleReplySent: true };
|
||||
|
||||
@@ -176,6 +176,33 @@ describe("deliverDiscordReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips assistant scaffolding from explicit tool progress payloads", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: [
|
||||
"<think>private reasoning</think>",
|
||||
'<tool_call>{"name":"x"}</tool_call>',
|
||||
"🛠️ run git status",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "tool",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [{ text: "🛠️ run git status" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips internal execution trace lines at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
@@ -183,6 +210,7 @@ describe("deliverDiscordReply", () => {
|
||||
text: [
|
||||
"📊 Session Status: current",
|
||||
"🛠️ run git status",
|
||||
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
"🛠️ `gh pr view`",
|
||||
"🛠️ `docker compose up`",
|
||||
"🛠️ elevated · `cd /tmp && pnpm test`",
|
||||
@@ -204,6 +232,26 @@ describe("deliverDiscordReply", () => {
|
||||
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
|
||||
});
|
||||
|
||||
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
sanitizeAssistantVisibleText,
|
||||
sanitizeAssistantVisibleTextWithProfile,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
|
||||
|
||||
const DISCORD_INTERNAL_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
|
||||
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
|
||||
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
|
||||
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
|
||||
/^(?:>\s*)?(?:analysis|commentary|thinking|reasoning)\s*[:=]/i;
|
||||
|
||||
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(
|
||||
@@ -36,7 +35,11 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function stripDiscordInternalTraceLines(text: string): string {
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function stripDiscordInternalChannelLines(text: string): string {
|
||||
let inFence = false;
|
||||
const kept: string[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
@@ -45,31 +48,20 @@ function stripDiscordInternalTraceLines(text: string): string {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
if (!inFence) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!inFence && DISCORD_INTERNAL_CHANNEL_LINE_RE.test(line.trim())) {
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelText(text: string): string {
|
||||
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
|
||||
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
|
||||
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
|
||||
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutTraceLines).trim();
|
||||
const withoutChannelLines = stripDiscordInternalChannelLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutChannelLines).trim();
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
@@ -82,7 +74,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
const safeText =
|
||||
typeof payload.text === "string"
|
||||
? preserveVerboseToolProgress
|
||||
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
|
||||
? collapseExcessBlankLines(
|
||||
sanitizeAssistantVisibleTextWithProfile(payload.text, "tool-progress"),
|
||||
).trim()
|
||||
: sanitizeDiscordFrontChannelText(payload.text)
|
||||
: payload.text;
|
||||
const nextPayload =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpusError, OpusErrorCode } from "libopus-wasm";
|
||||
import { OpusError } from "libopus-wasm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
analyzeVoiceReceiveError,
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
noteVoiceDecryptFailure,
|
||||
} from "./receive-recovery.js";
|
||||
|
||||
const OPUS_INVALID_PACKET_CODE = -4;
|
||||
|
||||
describe("voice receive recovery", () => {
|
||||
it("treats passthrough-disabled decrypt errors as decrypt failures", () => {
|
||||
expect(
|
||||
@@ -34,9 +36,7 @@ describe("voice receive recovery", () => {
|
||||
|
||||
it("treats corrupt Opus packets as non-recoverable decode noise", () => {
|
||||
expect(
|
||||
analyzeVoiceReceiveError(
|
||||
new OpusError(OpusErrorCode.InvalidPacket, "not inspected", "decode"),
|
||||
),
|
||||
analyzeVoiceReceiveError(new OpusError(OPUS_INVALID_PACKET_CODE, "not inspected", "decode")),
|
||||
).toEqual({
|
||||
message: "not inspected",
|
||||
isAbortLike: false,
|
||||
@@ -50,7 +50,7 @@ describe("voice receive recovery", () => {
|
||||
const analysis = analyzeVoiceReceiveError({
|
||||
name: "OpusError",
|
||||
message: "libopus decode failed (-4): corrupted stream",
|
||||
code: OpusErrorCode.InvalidPacket,
|
||||
code: OPUS_INVALID_PACKET_CODE,
|
||||
codeName: "InvalidPacket",
|
||||
operation: "decode",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpusErrorCode, isOpusError } from "libopus-wasm";
|
||||
import { OpusError } from "libopus-wasm";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
|
||||
@@ -6,6 +6,7 @@ const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
|
||||
const DECRYPT_FAILURE_MARKER = "DecryptionFailed(";
|
||||
const DAVE_PASSTHROUGH_DISABLED_MARKER = "UnencryptedWhenPassthroughDisabled";
|
||||
const WASM_MEMORY_ACCESS_MARKER = "memory access out of bounds";
|
||||
const OPUS_INVALID_PACKET_CODE = -4;
|
||||
|
||||
export const DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS = 30;
|
||||
export const DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS = 15;
|
||||
@@ -83,10 +84,24 @@ function isAbortLikeReceiveError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function isOpusDecodeInvalidPacketError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
const maybeOpusError = err as {
|
||||
name?: unknown;
|
||||
code?: unknown;
|
||||
codeName?: unknown;
|
||||
operation?: unknown;
|
||||
};
|
||||
const isDecodeOperation =
|
||||
maybeOpusError.operation === "decode" || maybeOpusError.operation === "decodeFloat";
|
||||
const isInvalidPacket =
|
||||
maybeOpusError.code === OPUS_INVALID_PACKET_CODE ||
|
||||
maybeOpusError.codeName === "InvalidPacket";
|
||||
return (
|
||||
isOpusError(err) &&
|
||||
err.code === OpusErrorCode.InvalidPacket &&
|
||||
(err.operation === "decode" || err.operation === "decodeFloat")
|
||||
isDecodeOperation &&
|
||||
isInvalidPacket &&
|
||||
(err instanceof OpusError || maybeOpusError.name === "OpusError")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
3
extensions/feishu/runtime-setter-api.ts
Normal file
3
extensions/feishu/runtime-setter-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Narrow entry point for setFeishuRuntime. Keep setup/runtime registration
|
||||
// from pulling in the broader Feishu runtime-api barrel.
|
||||
export { setFeishuRuntime } from "./src/runtime.js";
|
||||
@@ -17,5 +17,16 @@ describe("feishu setup entry", () => {
|
||||
expect(setupEntry.features).toEqual({ legacyStateMigrations: true });
|
||||
expect(typeof setupEntry.loadSetupPlugin).toBe("function");
|
||||
expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function");
|
||||
expect(typeof setupEntry.setChannelRuntime).toBe("function");
|
||||
});
|
||||
|
||||
it("wires the Feishu runtime from setup-only registration", async () => {
|
||||
const { default: setupEntry } = await import("./setup-entry.js");
|
||||
const runtime = { channel: { inbound: { run: vi.fn() } } };
|
||||
|
||||
setupEntry.setChannelRuntime?.(runtime as never);
|
||||
|
||||
const { getFeishuRuntime } = await import("./src/runtime.js");
|
||||
expect(getFeishuRuntime()).toBe(runtime);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,4 +17,8 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-setter-api.js",
|
||||
exportName: "setFeishuRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
6
extensions/googlechat/directory-contract-api.ts
Normal file
6
extensions/googlechat/directory-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { googlechatDirectoryAdapter } from "./src/channel.adapters.js";
|
||||
|
||||
export const googlechatDirectoryContractPlugin = {
|
||||
id: "googlechat",
|
||||
directory: googlechatDirectoryAdapter,
|
||||
};
|
||||
@@ -1,8 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.6.3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.6.2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.6.1
|
||||
|
||||
@@ -73,6 +73,12 @@ import {
|
||||
markMemoryTargetSessionFilesDirty,
|
||||
runMemoryTargetedSessionSync,
|
||||
} from "./manager-targeted-sync.js";
|
||||
import {
|
||||
countChokidarWatchedEntries,
|
||||
type MemoryWatchPressureUnit,
|
||||
type MemoryWatchPressureWarningState,
|
||||
warnIfMemoryWatchPressureHigh,
|
||||
} from "./watch-pressure.js";
|
||||
import {
|
||||
recordMemoryWatchEventPath,
|
||||
settleMemoryWatchEventPaths,
|
||||
@@ -104,6 +110,7 @@ const SESSION_DIRTY_DEBOUNCE_MS = 5000;
|
||||
const SESSION_DELTA_READ_CHUNK_BYTES = 64 * 1024;
|
||||
const SESSION_SYNC_YIELD_EVERY = 10;
|
||||
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
|
||||
const MEMORY_WATCH_PRESSURE_STARTUP_CHECK_DELAY_MS = 10_000;
|
||||
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
|
||||
".git",
|
||||
"node_modules",
|
||||
@@ -238,10 +245,12 @@ export abstract class MemoryManagerSyncOps {
|
||||
protected sessionUnsubscribe: (() => void) | null = null;
|
||||
protected fallbackReason?: string;
|
||||
protected intervalTimer: NodeJS.Timeout | null = null;
|
||||
protected memoryWatchPressureStartupTimer: NodeJS.Timeout | null = null;
|
||||
protected closed = false;
|
||||
protected dirty = false;
|
||||
protected pendingWatchPaths: MemoryWatchSettleQueue = new Map();
|
||||
protected sessionsDirty = false;
|
||||
private readonly memoryWatchPressureWarning: MemoryWatchPressureWarningState = { shown: false };
|
||||
protected sessionsDirtyFiles = new Set<string>();
|
||||
protected sessionPendingFiles = new Set<string>();
|
||||
protected sessionDeltas = new Map<
|
||||
@@ -567,8 +576,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
};
|
||||
// Native recursive fs.watch for directory paths — one watcher per
|
||||
// directory on macOS (FSEvents) and Windows (ReadDirectoryChangesW).
|
||||
// Avoids chokidar's per-file fs.watch fan-out that opened ~12k REG FDs
|
||||
// on multi-thousand-`.md` memory trees (issue #86613).
|
||||
// Avoids chokidar's per-file fs.watch fan-out on large memory trees.
|
||||
//
|
||||
// Linux is intentionally handled by a separate directory-tree watcher
|
||||
// below: Node's `fs.watch(dir, { recursive: true })` routes through
|
||||
@@ -597,23 +605,67 @@ export abstract class MemoryManagerSyncOps {
|
||||
if (existingWatcher) {
|
||||
existingWatcher.add(Array.from(fileWatchPaths));
|
||||
} else {
|
||||
this.watcher = resolveMemoryWatchFactory()(Array.from(fileWatchPaths), {
|
||||
const watcher = resolveMemoryWatchFactory()(Array.from(fileWatchPaths), {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath, stats) =>
|
||||
shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
|
||||
});
|
||||
this.watcher.on("add", markDirty);
|
||||
this.watcher.on("change", markDirty);
|
||||
this.watcher.on("unlink", markDirty);
|
||||
this.watcher.on("unlinkDir", markDirty);
|
||||
this.watcher.on("error", (err) => {
|
||||
this.watcher = watcher;
|
||||
watcher.on("add", markDirty);
|
||||
watcher.on("change", markDirty);
|
||||
watcher.on("unlink", markDirty);
|
||||
watcher.on("unlinkDir", markDirty);
|
||||
watcher.on("error", (err) => {
|
||||
// File watcher errors (e.g., ENOSPC) should not crash the gateway.
|
||||
// Log the error and continue - memory search still works without auto-sync.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`memory watcher error: ${message}`);
|
||||
});
|
||||
watcher.once("ready", () => {
|
||||
this.warnIfMemoryWatchPressure(countChokidarWatchedEntries(watcher), "paths");
|
||||
});
|
||||
}
|
||||
}
|
||||
this.scheduleMemoryWatchPressureStartupCheck();
|
||||
}
|
||||
|
||||
private scheduleMemoryWatchPressureStartupCheck(): void {
|
||||
if (
|
||||
this.memoryWatchPressureStartupTimer ||
|
||||
this.memoryWatchPressureWarning.shown ||
|
||||
this.closed ||
|
||||
(this.nativeMemoryWatchPairs.length === 0 && !this.watcher)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.memoryWatchPressureStartupTimer = setTimeout(() => {
|
||||
this.memoryWatchPressureStartupTimer = null;
|
||||
if (this.closed || this.memoryWatchPressureWarning.shown) {
|
||||
return;
|
||||
}
|
||||
if (this.watcher) {
|
||||
this.warnIfMemoryWatchPressure(countChokidarWatchedEntries(this.watcher), "paths");
|
||||
}
|
||||
if (this.memoryWatchPressureWarning.shown) {
|
||||
return;
|
||||
}
|
||||
let directoryCount = 0;
|
||||
for (const pair of this.nativeMemoryWatchPairs) {
|
||||
directoryCount += pair.treeWatchers?.size ?? 0;
|
||||
}
|
||||
this.warnIfMemoryWatchPressure(directoryCount, "directories");
|
||||
}, MEMORY_WATCH_PRESSURE_STARTUP_CHECK_DELAY_MS);
|
||||
}
|
||||
|
||||
private warnIfMemoryWatchPressure(count: number, unit: MemoryWatchPressureUnit): void {
|
||||
warnIfMemoryWatchPressureHigh(
|
||||
this.memoryWatchPressureWarning,
|
||||
count,
|
||||
unit,
|
||||
"Large memory folders or extraPaths can make OpenClaw run out of file watchers or open files.",
|
||||
"Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
(message) => log.warn(message),
|
||||
);
|
||||
}
|
||||
|
||||
private currentMemoryChokidarWatcher(): FSWatcher | null {
|
||||
@@ -704,9 +756,8 @@ export abstract class MemoryManagerSyncOps {
|
||||
(_eventType, filename) => {
|
||||
// Per Node docs `filename` can be null on some platforms even
|
||||
// when the parent watcher is otherwise supported. Treat null
|
||||
// as an unknown event and re-check the watched directory's
|
||||
// inode (clawsweeper review [P2] 5df68c…); otherwise filter
|
||||
// by basename so sibling events don't trigger reattach.
|
||||
// as an unknown event and re-check the watched directory's inode;
|
||||
// otherwise filter by basename so sibling events don't trigger reattach.
|
||||
if (filename !== null && filename !== baseName) {
|
||||
return;
|
||||
}
|
||||
@@ -1076,19 +1127,23 @@ export abstract class MemoryManagerSyncOps {
|
||||
}
|
||||
// No chokidar watcher exists yet. Spin one up just for this directory
|
||||
// so the periodic-sync gap is closed.
|
||||
this.watcher = resolveMemoryWatchFactory()([dir], {
|
||||
const watcher = resolveMemoryWatchFactory()([dir], {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath, stats) =>
|
||||
shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
|
||||
});
|
||||
this.watcher.on("add", markDirty);
|
||||
this.watcher.on("change", markDirty);
|
||||
this.watcher.on("unlink", markDirty);
|
||||
this.watcher.on("unlinkDir", markDirty);
|
||||
this.watcher.on("error", (err) => {
|
||||
this.watcher = watcher;
|
||||
watcher.on("add", markDirty);
|
||||
watcher.on("change", markDirty);
|
||||
watcher.on("unlink", markDirty);
|
||||
watcher.on("unlinkDir", markDirty);
|
||||
watcher.on("error", (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`memory watcher error: ${message}`);
|
||||
});
|
||||
watcher.once("ready", () => {
|
||||
this.warnIfMemoryWatchPressure(countChokidarWatchedEntries(watcher), "paths");
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(`failed to attach chokidar fallback for ${dir}: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
protected override sessionWatchTimer: NodeJS.Timeout | null = null;
|
||||
protected override sessionUnsubscribe: (() => void) | null = null;
|
||||
protected override intervalTimer: NodeJS.Timeout | null = null;
|
||||
protected override memoryWatchPressureStartupTimer: NodeJS.Timeout | null = null;
|
||||
protected override closed = false;
|
||||
protected override dirty = false;
|
||||
protected override sessionsDirty = false;
|
||||
@@ -1120,6 +1121,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
clearInterval(this.intervalTimer);
|
||||
this.intervalTimer = null;
|
||||
}
|
||||
if (this.memoryWatchPressureStartupTimer) {
|
||||
clearTimeout(this.memoryWatchPressureStartupTimer);
|
||||
this.memoryWatchPressureStartupTimer = null;
|
||||
}
|
||||
if (this.watcher) {
|
||||
await this.watcher.close();
|
||||
this.watcher = null;
|
||||
|
||||
@@ -23,21 +23,33 @@ const {
|
||||
// execute, so we resolve the same Symbol.for keys inline here.
|
||||
const chokidarKey = Symbol.for("openclaw.test.memoryWatchFactory");
|
||||
const nativeKey = Symbol.for("openclaw.test.memoryNativeWatchFactory");
|
||||
type ChokidarEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
|
||||
type ChokidarEvent = "add" | "change" | "unlink" | "unlinkDir" | "error" | "ready";
|
||||
type ChokidarCallback = (...args: unknown[]) => void;
|
||||
function createMockChokidarWatcher() {
|
||||
const handlers = new Map<ChokidarEvent, ChokidarCallback[]>();
|
||||
const onceHandlers = new Map<ChokidarEvent, ChokidarCallback[]>();
|
||||
const watcher = {
|
||||
watchedEntries: {} as Record<string, string[]>,
|
||||
on: vi.fn((event: ChokidarEvent, callback: ChokidarCallback) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
|
||||
return watcher;
|
||||
}),
|
||||
once: vi.fn((event: ChokidarEvent, callback: ChokidarCallback) => {
|
||||
onceHandlers.set(event, [...(onceHandlers.get(event) ?? []), callback]);
|
||||
return watcher;
|
||||
}),
|
||||
add: vi.fn((_path: string | string[]) => watcher),
|
||||
close: vi.fn(async () => undefined),
|
||||
getWatched: vi.fn(() => watcher.watchedEntries),
|
||||
emit: (event: ChokidarEvent, ...args: unknown[]) => {
|
||||
for (const callback of handlers.get(event) ?? []) {
|
||||
callback(...args);
|
||||
}
|
||||
const callbacks = onceHandlers.get(event) ?? [];
|
||||
onceHandlers.delete(event);
|
||||
for (const callback of callbacks) {
|
||||
callback(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
return watcher;
|
||||
@@ -533,6 +545,35 @@ describe("memory watcher config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when Linux memory watching tracks many directories", async () => {
|
||||
const originalPlatformValue = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
|
||||
const root = path.join(workspaceDir, "memory");
|
||||
for (let i = 0; i < 2_001; i += 1) {
|
||||
await fs.mkdir(path.join(root, `topic-${i}`));
|
||||
}
|
||||
const cfg = createWatcherConfig({ extraPaths: [] });
|
||||
vi.useFakeTimers();
|
||||
|
||||
await expectWatcherManager(cfg);
|
||||
expect(memoryLoggerWarn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory file watching is tracking 2002 directories."),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(memoryLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory file watching is tracking 2002 directories."),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatformValue,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches Linux native watchers for new subdirectories", async () => {
|
||||
const originalPlatformValue = process.platform;
|
||||
try {
|
||||
@@ -717,7 +758,6 @@ describe("memory watcher config", () => {
|
||||
it("routes directories through native recursive watch on Windows", async () => {
|
||||
// Windows uses ReadDirectoryChangesW for `fs.watch(dir, { recursive: true })`,
|
||||
// which is a single-watcher native recursive backend (constant FD profile).
|
||||
// The PR explicitly opts Windows into the native path alongside macOS.
|
||||
const originalPlatformLocal = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
@@ -817,10 +857,9 @@ describe("memory watcher config", () => {
|
||||
it("treats null parent-watcher filename as an unknown event and re-checks the inode", async () => {
|
||||
// Node fs.watch can emit `filename: null` on some platforms even on
|
||||
// otherwise-supported recursive backends; the parent watcher must
|
||||
// not silently drop it — it must fall through to the inode check
|
||||
// (clawsweeper [P2] on 5df68c…). With statSync returning the
|
||||
// recorded inode (no real replacement), the no-action path is taken
|
||||
// and no teardown/reattach happens.
|
||||
// not silently drop it. With statSync returning the recorded inode
|
||||
// (no real replacement), the no-action path is taken and no
|
||||
// teardown/reattach happens.
|
||||
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
|
||||
const cfg = createWatcherConfig({ extraPaths: [] });
|
||||
await expectWatcherManager(cfg);
|
||||
@@ -861,7 +900,7 @@ describe("memory watcher config", () => {
|
||||
// paired parent watcher must also be closed — otherwise a later
|
||||
// root-replacement event would reattach native coverage on top of an
|
||||
// already-installed chokidar fallback, creating duplicate handles
|
||||
// and event paths (clawsweeper [P3] on 5df68c…).
|
||||
// and event paths.
|
||||
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
|
||||
const cfg = createWatcherConfig({ extraPaths: [] });
|
||||
await expectWatcherManager(cfg);
|
||||
@@ -975,4 +1014,28 @@ describe("memory watcher config", () => {
|
||||
expect(chokidarWatcher?.emit("error", new Error("watcher error: ENOSPC"))).toBeUndefined();
|
||||
expect(memoryLoggerWarn).toHaveBeenCalledWith("memory watcher error: watcher error: ENOSPC");
|
||||
});
|
||||
|
||||
it("warns when chokidar memory watching tracks many paths", async () => {
|
||||
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
|
||||
const cfg = createWatcherConfig();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await expectWatcherManager(cfg);
|
||||
|
||||
const chokidarWatcher = createdChokidarWatchers[0];
|
||||
if (!chokidarWatcher) {
|
||||
throw new Error("expected chokidar watcher");
|
||||
}
|
||||
chokidarWatcher.watchedEntries = {
|
||||
[workspaceDir]: Array.from({ length: 2_001 }, (_value, index) => `${index}.md`),
|
||||
};
|
||||
expect(memoryLoggerWarn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory file watching is tracking 2002 paths."),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(memoryLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory file watching is tracking 2002 paths."),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,13 @@ const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
const { watchMock } = vi.hoisted(() => ({
|
||||
watchMock: vi.fn(() => {
|
||||
const watcher = new EventEmitter();
|
||||
const watcher = new EventEmitter() as EventEmitter & {
|
||||
watchedEntries: Record<string, string[]>;
|
||||
};
|
||||
watcher.watchedEntries = {};
|
||||
return Object.assign(watcher, {
|
||||
close: vi.fn(async () => undefined),
|
||||
getWatched: vi.fn(() => watcher.watchedEntries),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
@@ -534,8 +538,8 @@ describe("QmdMemoryManager", () => {
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
const watcher = watchMock.mock.results[0]?.value as {
|
||||
emit: (event: string, ...args: unknown[]) => boolean;
|
||||
const watcher = watchMock.mock.results[0]?.value as EventEmitter & {
|
||||
watchedEntries: Record<string, string[]>;
|
||||
};
|
||||
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
|
||||
expect(initialUpdateCalls).toHaveLength(0);
|
||||
@@ -549,6 +553,11 @@ describe("QmdMemoryManager", () => {
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
|
||||
watcher.watchedEntries = {
|
||||
[workspaceDir]: Array.from({ length: 2_001 }, (_value, index) => `${index}.md`),
|
||||
};
|
||||
watcher.emit("ready");
|
||||
expectMockMessageContains(logWarnMock, "Memory file watching is tracking 2002 paths.");
|
||||
|
||||
const notesPath = path.join(workspaceDir, "notes.md");
|
||||
await fs.writeFile(notesPath, "hello");
|
||||
|
||||
@@ -61,6 +61,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { asRecord } from "../dreaming-shared.js";
|
||||
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
|
||||
import {
|
||||
countChokidarWatchedEntries,
|
||||
type MemoryWatchPressureWarningState,
|
||||
warnIfMemoryWatchPressureHigh,
|
||||
} from "./watch-pressure.js";
|
||||
import {
|
||||
recordMemoryWatchEventPath,
|
||||
settleMemoryWatchEventPaths,
|
||||
@@ -364,6 +369,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private watchTimer: NodeJS.Timeout | null = null;
|
||||
private readonly pendingWatchPaths: MemoryWatchSettleQueue = new Map();
|
||||
private readonly watchPressureWarning: MemoryWatchPressureWarningState = { shown: false };
|
||||
private pendingUpdate: Promise<void> | null = null;
|
||||
private queuedForcedUpdate: Promise<void> | null = null;
|
||||
private queuedForcedRuns = 0;
|
||||
@@ -1604,25 +1610,38 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const watchPathList = Array.from(watchPaths);
|
||||
const startTime = Date.now();
|
||||
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
|
||||
this.watcher = chokidar.watch(watchPathList, {
|
||||
const watcher = chokidar.watch(watchPathList, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
|
||||
});
|
||||
this.watcher = watcher;
|
||||
const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {
|
||||
recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats);
|
||||
this.dirty = true;
|
||||
this.scheduleWatchSync();
|
||||
};
|
||||
this.watcher.on("add", markDirty);
|
||||
this.watcher.on("change", markDirty);
|
||||
this.watcher.on("unlink", markDirty);
|
||||
this.watcher.once("ready", () => {
|
||||
watcher.on("add", markDirty);
|
||||
watcher.on("change", markDirty);
|
||||
watcher.on("unlink", markDirty);
|
||||
watcher.once("ready", () => {
|
||||
this.warnIfWatchPressure(countChokidarWatchedEntries(watcher));
|
||||
log.info(
|
||||
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private warnIfWatchPressure(count: number): void {
|
||||
warnIfMemoryWatchPressureHigh(
|
||||
this.watchPressureWarning,
|
||||
count,
|
||||
"paths",
|
||||
"Large QMD collections can make OpenClaw run out of file watchers or open files.",
|
||||
"Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually or with sync.intervalMinutes.",
|
||||
(message) => log.warn(message),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCollectionWatchPath(collection: ManagedCollection): string {
|
||||
return path.join(path.normalize(collection.path), collection.pattern);
|
||||
}
|
||||
|
||||
34
extensions/memory-core/src/memory/watch-pressure.ts
Normal file
34
extensions/memory-core/src/memory/watch-pressure.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { FSWatcher } from "chokidar";
|
||||
|
||||
export const MEMORY_WATCH_PRESSURE_WARNING_THRESHOLD = 2_000;
|
||||
|
||||
export type MemoryWatchPressureUnit = "directories" | "paths";
|
||||
|
||||
export type MemoryWatchPressureWarningState = {
|
||||
shown: boolean;
|
||||
};
|
||||
|
||||
export function countChokidarWatchedEntries(watcher: FSWatcher): number {
|
||||
const watched = watcher.getWatched();
|
||||
let count = Object.keys(watched).length;
|
||||
for (const entries of Object.values(watched)) {
|
||||
count += entries.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function warnIfMemoryWatchPressureHigh(
|
||||
state: MemoryWatchPressureWarningState,
|
||||
count: number,
|
||||
unit: MemoryWatchPressureUnit,
|
||||
pressureDetail: string,
|
||||
remediation: string,
|
||||
warn: (message: string) => void,
|
||||
): boolean {
|
||||
if (state.shown || count <= MEMORY_WATCH_PRESSURE_WARNING_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
state.shown = true;
|
||||
warn(`Memory file watching is tracking ${count} ${unit}. ${pressureDetail} ${remediation}`);
|
||||
return true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user