Compare commits

..

4 Commits

Author SHA1 Message Date
Nimrod Gutman
4617949fc9 fix(plugins): allowlist bundled dep specs 2026-04-22 15:18:06 +03:00
Nimrod Gutman
ac1e6f0374 fix(plugins): reject local bundled dep specs 2026-04-22 14:54:18 +03:00
Nimrod Gutman
458fcd97db fix(plugins): harden bundled runtime dep postinstall 2026-04-22 14:29:29 +03:00
Nimrod Gutman
f1ea5b34f3 fix(plugins): eagerly install bundled runtime deps 2026-04-22 14:17:21 +03:00
829 changed files with 8214 additions and 47632 deletions

View File

@@ -22,17 +22,16 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Windows: `90m`
- aggregate npm-update wrapper: `150m`
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
- Actual OpenClaw npm install/update phases are a stricter signal than whole-lane caps: install phases should normally finish within 7 minutes, and update phases should normally show meaningful progress within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s without new markers, start diagnosis from that phase log and guest process state. Current Windows update phases can still pass after roughly 10-15 minutes because `doctor --fix` may install bundled plugin runtime deps; keep the script hard cap near 20 minutes unless the log is truly stale.
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: install phases should finish within 7 minutes, and update phases should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`
- `timeout --foreground 75m pnpm test:parallels:linux -- --json`
Keep each lane in its own shell/session and track the run directory for each one. Before starting the matrix, run any required host build/package gate to completion. When current-main tgz packaging is needed, the smoke scripts hold a shared package lock through `pnpm build`, inventory/staging, and `npm pack`; if that lock is missing or broken, serialize the matrix instead of accepting concurrent `dist` mutation.
Keep each lane in its own shell/session and track the run directory for each one.
- Do not run multiple smoke lanes against the same guest family at once. Tahoe lanes share the host HTTP port, and Windows/Linux lanes can collide on snapshot restore/start state if two jobs touch the same VM concurrently.
- Do not run the aggregate `pnpm test:parallels:npm-update` wrapper in parallel with individual macOS/Windows/Linux smoke lanes; it touches the same guest families and snapshots.
- Do not start Parallels lanes while any unrelated host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run unrelated build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- Do not start Parallels lanes while any host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run the build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
- While running or optimizing the matrix, record wall-clock duration per lane and the slowest phase from `/tmp/openclaw-parallels-*` logs. Use that timing before changing smoke order, timeouts, or helper behavior.
- If a host build changes tracked generated files such as `src/canvas-host/a2ui/.bundle.hash`, stop before spending VM time. Commit the generated artifact separately or fix the generator drift, then rerun the smallest affected lane.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.

View File

@@ -22,7 +22,7 @@ jobs:
permissions:
issues: write
pull-requests: write
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@v3
id: app-token

View File

@@ -10,7 +10,7 @@ permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha)) }}
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-{1}', github.workflow, github.ref) || format('{0}-{1}-{2}', github.workflow, github.ref, github.sha)) }}
cancel-in-progress: true
env:
@@ -251,9 +251,9 @@ jobs:
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_extension_fast: hasChangedExtensions && !isPush,
run_extension_fast: hasChangedExtensions,
extension_fast_matrix: createMatrix(
hasChangedExtensions && !isPush
hasChangedExtensions
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
@@ -284,6 +284,7 @@ jobs:
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-third-party", task: "build-third-party" },
]
: [],
),
@@ -304,7 +305,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -395,7 +396,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
@@ -418,8 +419,8 @@ jobs:
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify fast security jobs
@@ -452,7 +453,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -524,19 +525,15 @@ jobs:
- name: Cache dist build
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
path: dist/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
- name: Upload built runtime artifacts
- name: Upload dist artifact
uses: actions/upload-artifact@v7
with:
name: dist-runtime-build
path: dist-runtime-build.tar.zst
name: dist-build
path: dist/
compression-level: 0
retention-days: 1
- name: Upload A2UI bundle artifact
@@ -547,28 +544,13 @@ jobs:
include-hidden-files: true
retention-days: 1
- name: Smoke test CLI launcher help
run: node openclaw.mjs --help
- name: Smoke test CLI launcher status json
run: node openclaw.mjs status --json --timeout 1
- name: Smoke test built bundled plugin singleton
run: pnpm test:build:singleton
- name: Smoke test built bundled runtime deps
run: pnpm test:build:bundled-runtime-deps
- name: Check CLI startup memory
run: pnpm test:startup:memory
checks-fast-core:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -656,7 +638,7 @@ jobs:
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -739,8 +721,8 @@ jobs:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify channel contract shards
@@ -762,7 +744,7 @@ jobs:
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -827,7 +809,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -888,7 +870,6 @@ jobs:
- name: Run extension shard
env:
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
@@ -897,8 +878,8 @@ jobs:
contents: read
name: checks-node-extensions
needs: [preflight, checks-node-extensions-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify extension shards
@@ -915,8 +896,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -991,21 +972,12 @@ jobs:
echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV"
fi
- name: Restore dist cache
- name: Download dist artifact
if: matrix.task == 'test'
id: checks-dist-cache
uses: actions/cache@v5
uses: actions/download-artifact@v8
with:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Verify dist cache
if: matrix.task == 'test' && steps.checks-dist-cache.outputs.cache-hit != 'true'
run: |
echo "Missing same-run dist cache for ${RUNNER_OS}-dist-build-${GITHUB_SHA}" >&2
exit 1
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
if: matrix.task == 'test' || matrix.task == 'channels'
@@ -1040,7 +1012,7 @@ jobs:
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- name: Checkout
@@ -1117,7 +1089,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1240,8 +1212,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
if: always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1309,16 +1281,15 @@ jobs:
id: dist-cache
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
path: dist/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Verify dist cache
- name: Download dist artifact
if: steps.dist-cache.outputs.cache-hit != 'true'
run: |
echo "Missing same-run dist cache for ${RUNNER_OS}-dist-build-${GITHUB_SHA}" >&2
exit 1
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
@@ -1385,8 +1356,8 @@ jobs:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_checks == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify node test shards
@@ -1411,7 +1382,7 @@ jobs:
name: "extension-fast"
needs: [preflight]
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1481,8 +1452,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
if: always() && needs.preflight.outputs.run_check == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1490,22 +1461,16 @@ jobs:
include:
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
- check_name: check-prod-types
task: prod-types
runner: ubuntu-24.04
- check_name: check-lint
task: lint
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
- check_name: check-test-types
task: test-types
runner: ubuntu-24.04
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
steps:
- name: Checkout
shell: bash
@@ -1602,8 +1567,8 @@ jobs:
contents: read
name: "check"
needs: [preflight, check-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_check == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify check shards
@@ -1620,8 +1585,8 @@ jobs:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1633,8 +1598,12 @@ jobs:
group: extension-channels
- check_name: check-additional-extension-bundled
group: extension-bundled
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
- check_name: check-additional-extension-package-boundary-compile
group: extension-package-boundary-compile
- check_name: check-additional-extension-package-boundary-canary
group: extension-package-boundary-canary
- check_name: check-additional-runtime-topology-gateway
group: runtime-topology-gateway
- check_name: check-additional-runtime-topology-architecture
group: runtime-topology-architecture
steps:
@@ -1693,7 +1662,7 @@ jobs:
- name: Cache extension package boundary artifacts
id: extension-package-boundary-cache
if: matrix.group == 'extension-package-boundary'
if: matrix.group == 'extension-package-boundary-compile'
uses: actions/cache@v5
with:
path: |
@@ -1706,7 +1675,7 @@ jobs:
${{ runner.os }}-extension-package-boundary-v1-
- name: Preserve extension package boundary cache hit
if: matrix.group == 'extension-package-boundary' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true'
if: matrix.group == 'extension-package-boundary-compile' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true'
shell: bash
run: |
set -euo pipefail
@@ -1734,7 +1703,6 @@ jobs:
env:
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 6
shell: bash
run: |
@@ -1758,7 +1726,24 @@ jobs:
case "$ADDITIONAL_CHECK_GROUP" in
boundaries)
node scripts/run-additional-boundary-checks.mjs
run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports
run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging
run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries
run_check "lint:tmp:tsgo-core-boundary" pnpm run lint:tmp:tsgo-core-boundary
run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch
run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner
run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler
run_check "lint:plugins:no-monolithic-plugin-sdk-entry-imports" pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports
run_check "lint:plugins:no-extension-src-imports" pnpm run lint:plugins:no-extension-src-imports
run_check "lint:plugins:no-extension-test-core-imports" pnpm run lint:plugins:no-extension-test-core-imports
run_check "lint:plugins:plugin-sdk-subpaths-exported" pnpm run lint:plugins:plugin-sdk-subpaths-exported
run_check "deps:root-ownership:check" pnpm deps:root-ownership:check
run_check "web-search-provider-boundary" pnpm run lint:web-search-provider-boundaries
run_check "web-fetch-provider-boundary" pnpm run lint:web-fetch-provider-boundaries
run_check "extension-src-outside-plugin-sdk-boundary" pnpm run lint:extensions:no-src-outside-plugin-sdk
run_check "extension-plugin-sdk-internal-boundary" pnpm run lint:extensions:no-plugin-sdk-internal
run_check "extension-relative-outside-package-boundary" pnpm run lint:extensions:no-relative-outside-package
run_check "lint:ui:no-raw-window-open" pnpm lint:ui:no-raw-window-open
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
@@ -1766,10 +1751,18 @@ jobs:
extension-bundled)
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
;;
extension-package-boundary)
extension-package-boundary-compile)
run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile
;;
extension-package-boundary-canary)
run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary
;;
runtime-topology-gateway)
if [ "$RUN_CONTROL_UI_I18N" = "true" ]; then
run_check "ui:i18n:check" pnpm ui:i18n:check
fi
run_check "gateway-watch-regression" pnpm test:gateway:watch-regression
;;
runtime-topology-architecture)
run_check "check:architecture" pnpm check:architecture
;;
@@ -1781,13 +1774,39 @@ jobs:
exit "$failures"
check-additional-runtime-topology-gateway:
- name: Upload gateway watch regression artifacts
if: always() && matrix.group == 'runtime-topology-gateway'
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
check-additional:
permissions:
contents: read
name: "check-additional-runtime-topology-gateway"
name: "check-additional"
needs: [preflight, check-additional-shard]
if: always() && needs.preflight.outputs.run_check_additional == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1843,74 +1862,39 @@ jobs:
with:
install-bun: "false"
- name: Download built runtime artifacts
- name: Restore dist cache
id: build-smoke-dist-cache
if: github.event_name == 'push'
uses: actions/cache@v5
with:
path: dist/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Download dist artifact
if: github.event_name == 'push' && steps.build-smoke-dist-cache.outputs.cache-hit != 'true'
uses: actions/download-artifact@v8
with:
name: dist-runtime-build
path: .local/dist-runtime-build
name: dist-build
path: dist/
- name: Restore built runtime artifacts
run: |
tar -xf .local/dist-runtime-build/dist-runtime-build.tar.zst --use-compress-program unzstd
test -f dist/entry.js
test -f dist/.buildstamp
test -d dist-runtime
- name: Build dist
if: github.event_name != 'push'
run: pnpm build
- name: Check Control UI i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Smoke test CLI launcher help
run: node openclaw.mjs --help
- name: Run gateway watch regression
run: node scripts/check-gateway-watch-regression.mjs --skip-build
- name: Smoke test CLI launcher status json
run: node openclaw.mjs status --json --timeout 1
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
- name: Smoke test built bundled plugin singleton
run: pnpm test:build:singleton
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, check-additional-runtime-topology-gateway]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
GATEWAY_RESULT: ${{ needs.check-additional-runtime-topology-gateway.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
- name: Smoke test built bundled runtime deps
run: pnpm test:build:bundled-runtime-deps
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
- name: Check CLI startup memory
run: pnpm test:startup:memory
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
@@ -1918,7 +1902,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1982,7 +1966,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ubuntu-24.04
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -2013,11 +1997,11 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-windows-2025' || 'windows-2025' }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the smaller Windows runner.
# Keep total concurrency predictable on the 32 vCPU runner.
OPENCLAW_VITEST_MAX_WORKERS: 1
OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
defaults:
@@ -2114,9 +2098,9 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -2133,6 +2117,18 @@ jobs:
with:
install-bun: "false"
- name: Download dist artifact
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
@@ -2255,7 +2251,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -2365,6 +2361,9 @@ jobs:
build-play)
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
;;
build-third-party)
./gradlew --no-daemon --build-cache :app:assembleThirdPartyDebug
;;
*)
echo "Unsupported Android task: $TASK" >&2
exit 1

View File

@@ -78,7 +78,7 @@ jobs:
install-smoke:
needs: [preflight]
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: blacksmith-32vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -91,11 +91,6 @@ jobs:
# Blacksmith's builder owns the Docker layer cache; keep smoke builds off
# explicit gha cache directives so local tags still load cleanly.
- name: Run QR package install smoke
env:
OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1"
run: bash scripts/e2e/qr-import-docker.sh
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
@@ -112,12 +107,6 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
@@ -219,29 +208,3 @@ jobs:
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
run: bash scripts/test-install-sh-docker.sh
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_install_smoke == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 8
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout CLI
uses: actions/checkout@v6
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
- name: Setup Node environment for package smoke
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "true"
- name: Run fast bundled plugin Docker E2E
env:
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
run: timeout 120s pnpm test:docker:bundled-channel-deps:fast

View File

@@ -30,7 +30,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@v3
id: app-token
@@ -439,7 +439,7 @@ jobs:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@v3
id: app-token
@@ -737,7 +737,7 @@ jobs:
label-issues:
permissions:
issues: write
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@v3
id: app-token

View File

@@ -71,9 +71,13 @@ jobs:
- name: Build private QA runtime
run: pnpm build
# The approval-turn sentinel still runs inside the full parity pack below.
# Keep the exact mock read-plan contract in deterministic unit tests instead
# of paying for a separate full-runtime preflight that has been flaky in CI.
- name: Run parity preflight
run: |
pnpm openclaw qa suite \
--provider-mode mock-openai \
--model openai/gpt-5.4 \
--alt-model anthropic/claude-opus-4-6 \
--preflight
- name: Run GPT-5.4 lane
run: |
pnpm openclaw qa suite \

View File

@@ -19,7 +19,7 @@ env:
jobs:
no-tabs:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -51,7 +51,7 @@ jobs:
actionlint:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -83,7 +83,7 @@ jobs:
generated-doc-baselines:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -6,126 +6,37 @@ Docs: https://docs.openclaw.ai
### Changes
- Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo.
- OpenAI/Responses: use OpenAI's native `web_search` tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed `web_search` tool.
- ACPX: add an explicit `openClawToolsMcpBridge` option that injects a core OpenClaw MCP server for selected built-in tools, starting with `cron`.
- Agents/sessions: add mailbox-style `sessions_list` filters for label, agent, and search plus visibility-scoped derived title and last-message previews. (#69839) Thanks @dangoZhang.
- Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add `agents.defaults.promptOverlays.gpt5.personality` as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback.
- Providers/xAI: add image generation, text-to-speech, and speech-to-text support, including `grok-imagine-image` / `grok-imagine-image-pro`, reference-image edits, six live xAI voices, MP3/WAV/PCM/G.711 TTS formats, `grok-stt` audio transcription, and xAI realtime transcription for Voice Call streaming. (#68694) Thanks @KateWilkins.
- Providers/STT: add Voice Call streaming transcription for Deepgram, ElevenLabs, and Mistral, and add ElevenLabs Scribe v2 batch audio transcription for inbound media.
- Models/commands: add `/models add <provider> <modelId>` so you can register a model from chat and use it without restarting the gateway; keep `/models` as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman.
- Pi/models: update the bundled pi packages to `0.68.1` and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed `opencode-go/kimi-k2.6`, Qwen, GLM, MiMo, and MiniMax entries.
- CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.
- WhatsApp/groups+direct: forward per-group and per-direct `systemPrompt` config into inbound context `GroupSystemPrompt` so configured per-chat behavioral instructions are injected on every turn. Supports `"*"` wildcard fallback and account-scoped overrides under `channels.whatsapp.accounts.<id>.{groups,direct}`; account maps fully replace root maps (no deep merge), matching the existing `requireMention` pattern. Closes #7011. (#59553) Thanks @Bluetegu.
- Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.
- Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as `tool_result` handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.
- Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy `exec` and `bash` tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.
- Codex harness/hooks: route native Codex app-server turns through `before_prompt_build` and emit `before_compaction` / `after_compaction` for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.
- Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async `tool_result` middleware, fire `after_tool_call` for Codex tool runs, and route mirrored Codex transcript writes through `before_message_write` so tool integrations stop diverging from Pi. Thanks @vincentkoc.
- Codex harness/hooks: fire `llm_input`, `llm_output`, and `agent_end` for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.
- Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub and Token Plan onboarding, docs, `hy3-preview` model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
- CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.
- Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
- CLI/Claude: keep compatible `claude-cli` runs on a warm stdio session and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.
### Fixes
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
- Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.
- Codex harness: drop invalid legacy app-server `serviceTier` values such as `"priority"` before native thread and turn requests, while keeping supported Codex tiers limited to `"fast"` and `"flex"`. Fixes #64815.
- Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.
- Docs/Codex harness: narrow native compaction docs to the current start/completion signals, without promising a readable summary or kept-entry audit list yet. (#69612) Thanks @91wan.
- Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.
- Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.
- Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048.
- Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.
- Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, let bare npm package names resolve back to tracked install records, and point already-installed `plugins install` attempts at `plugins update` / `--force` instead of a hook-pack fallback. Fixes #46955, #67957, and #68073.
- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded
`coding` and `messaging` sessions while preserving `minimal` profile and
`tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818.
- Plugins/startup: tolerate transient bundled-channel catalog/metadata drift while auto-enabling configured plugins, so CLI and gateway startup no longer crash when a channel id is known but its display metadata is unavailable.
- CLI/Claude: report CLI-backed reply runs as streaming while Claude/Codex CLI turns are still in flight, so WebChat keeps visible response state until the backend finishes. Fixes #70125.
- Slack/streaming: fall back to normal Slack replies for Slack Connect streams rejected before the SDK flushes its local buffer, so short replies no longer disappear or report success before Slack acknowledges delivery. Fixes #70295. (#70370) Thanks @mvanhorn.
- Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.
- Channels/sandbox: derive runtime policy keys for external direct messages that share the main conversation, so sandbox/tool policy no longer treats channel-originated DMs as local main-session runs.
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.
- Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.
- Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.
- Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only.
- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.
- Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc.
- Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc.
- Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron `deleteAfterRun` fallback cleanup. Fixes #69145, #68623, and #68827.
- MCP/gateway: tear down stdio MCP process trees on transport close and dispose bundled MCP runtimes during session delete/reset, preventing orphaned wrapper/server processes from accumulating. Fixes #68809 and #69465.
- Agents/MCP: retire bundled MCP runtimes after completed one-shot subagent cleanup and nested `sessions_send` steps, while keeping persistent subagent sessions warm.
- Config: render validation warnings with real line breaks instead of a literal `\n` sequence in CLI/audit output. Fixes #70140.
- Cron/doctor: repair malformed persisted cron job IDs through `openclaw doctor`, including legacy `jobId`, non-string `id`, and missing `id` rows, so `cron list` no longer needs display-layer coercion for corrupt store data. Fixes #70128.
- Discord: normalize prefixed channel targets only at the thread-binding API boundary, so `sessions_spawn({ runtime: "acp", thread: true })` can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.
- Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data.
- Discord: let `message` tool reactions resolve `user:<id>` DM targets and preserve `channels.discord.guilds.<guild>.channels.<channel>.requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441.
- Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.
- ACP/Codex: run the bundled Codex ACP harness with an isolated `CODEX_HOME` and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88.
- Gateway/client: keep long-running RPCs such as ACP `agent.wait` calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending.
- Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james.
- Telegram/polling: rebuild the polling HTTP transport after `getUpdates` 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys.
- Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored `media://inbound/*` attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.
- Slack/files: resolve `downloadFile` bot tokens from the runtime config when callers provide `cfg` without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.
- Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.
- Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.
- CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with `reason=transcript-missing` instead of silently starting fresh under `--resume`. Fixes #70177.
- CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.
- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.
- Diffs/viewer: re-read remote viewer access policy from live runtime config on each request, so toggling `plugins.entries.diffs.config.security.allowRemoteViewer` closes proxied viewer access immediately instead of waiting for a restart. Thanks @vincentkoc.
- Diffs/tooling: re-read `viewerBaseUrl`, presentation defaults, and viewer access policy from live runtime config, and fail closed when the live `diffs` plugin entry disappears instead of reviving startup viewer settings. Thanks @vincentkoc.
- Memory/LanceDB: stop resurrecting removed live `memory-lancedb` hook config from startup snapshots, so deleting or disabling the plugin entry shuts off auto-recall and auto-capture without a restart. Thanks @vincentkoc.
- Active Memory: stop reviving removed live `active-memory` config from startup snapshots, so removing the plugin entry turns the hook off immediately instead of waiting for a restart. Thanks @vincentkoc.
- Agents/subagents: drop bare `NO_REPLY` from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.
- Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads.
- CLI/channels: skip and cache bundled channel plugin, setup, and secrets load failures during read-only discovery, so one broken unused bundled channel cannot crash `openclaw status` or bootstrap secret scans.
- Memory/LanceDB: retry initialization after a failed LanceDB load and report unsupported Intel macOS native runtime clearly instead of caching the failure or repeatedly attempting an install that cannot work.
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.
- Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.
- Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed `gateway_start` hook, and move memory-core managed dreaming off the internal `gateway:startup` bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.
- Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so `plugins.allow` remains enforced and `doctor`/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141.
- Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.
- Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.
- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.
- Agents/OpenAI: treat exact `NO_REPLY` assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.
- Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware `final_answer` delivery, so split `MEDIA:<path>` lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.
- Anthropic/Claude Opus 4.7: normalize Opus 4.7 and `claude-cli` Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.
- Gateway/pairing webchat: render `/pair qr` replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.
- Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.
- Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.
- Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so `codex/gpt-5.x` sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.
- Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy `OPENCLAW_CODEX_APP_SERVER_GUARDIAN` shortcut is removed; use plugin config `appServer.mode: "guardian"` or `OPENCLAW_CODEX_APP_SERVER_MODE=guardian`. Thanks @pashpashpash.
- OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when `models.providers.openai.baseUrl` points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.
- Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper `loadConfig()` calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.
- Discord: pass resolved runtime config through guild and moderation action helpers, so thread-originated Discord commands can run channel, member, role, and guild actions without falling back to runtime config reads. (#70215) Thanks @szponeczek.
- CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram `streaming` into `accounts.default`.
- Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom `session.store` paths.
- Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in `openclaw update --json`.
- Ollama: forward OpenClaw thinking control to native `/api/chat` requests as top-level `think`, so `/think off` and `openclaw agent --thinking off` suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.
- Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.
- Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted `> Reasoning:` text, preventing `/reasoning on` from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.
- Discord: read `channel.parentId` through a safe accessor in the slash-command, reaction, and model-picker paths so partial `GuildThreadChannel` prototype getters no longer throw `Cannot access rawData on partial Channel` when commands like `/new` run from inside a thread. Fixes #69861. (#69908) Thanks @neeravmakwana.
- Discord: use safe channel name and parent accessors across voice command authorization, so `/vc` commands from partial Discord thread channels no longer crash on Carbon rawData getters. (#70199) Thanks @hanamizuki.
- Discord: make auto-thread parent transcript inheritance opt-in via `channels.discord.thread.inheritParent`, keeping newly created Discord thread sessions isolated by default while preserving explicit inheritance for configured accounts. Fixes #69907. (#69986) Thanks @Blahdude.
- Browser/Chrome MCP: reset cached existing-session control sessions when a `navigate_page` call times out, so one stuck navigation no longer poisons the browser profile until a gateway restart. (#69733) Thanks @ayeshakhalid192007-dev.
- Browser/Chrome MCP: propagate click timeouts and abort signals to existing-session actions so a stuck click fails fast and reconnects instead of poisoning the browser tool until gateway restart. (#63524) Thanks @dongseok0.
- Amazon Bedrock/prompt caching: resolve opaque application inference profile targets before injecting Bedrock cache points, require every routed target to support explicit cache points, and retry transient profile lookups instead of caching a false negative for the rest of the process. (#69953) Thanks @anirudhmarc and @vincentkoc.
- Gateway/channel health: base stale-socket recovery on provider-proven transport activity instead of inbound app-event freshness, preventing quiet Slack, Discord, Telegram, Matrix, and local-style channels from being restarted solely because no user traffic arrived. (#69833) Thanks @bek91.
- OpenCode Go: canonicalize stale bundled `opencode-go` base URLs from `/go` or `/go/v1` to `/zen/go` or `/zen/go/v1`, so older generated model metadata stops hitting the 404 HTML endpoint. (#69898)
- CLI/channels: honor `channels.<id>.enabled=false` as a hard read-only presence opt-out, so env vars, manifest env vars, or stale persisted auth state no longer make disabled channel plugins appear in status, doctor, or setup-only discovery.
- Channels/preview streaming: centralize draft-preview finalization so Slack, Discord, Mattermost, and Matrix no longer flush temporary preview messages for media/error finals, and preserve first-reply threading for normal fallback delivery.
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
- Gateway/session history: re-check current auth and `chat.history` scope before later SSE keepalives and transcript updates, so active session-history streams close before delivering post-revocation events.
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
- CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.
- Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so `openclaw doctor` and post-update doctor runs no longer crash with `Cannot read properties of undefined (reading 'clear')`. (#70135) Thanks @ngutman.
- Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.
- memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.
- Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.
@@ -136,17 +47,6 @@ Docs: https://docs.openclaw.ai
- OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc.
- CLI sessions: keep provider-owned CLI sessions through implicit daily expiry while preserving explicit reset behavior, and retain Claude CLI binding metadata across gateway agent requests. (#70106) Thanks @obviyus.
- fix(config): accept truncateAfterCompaction (#68395). Thanks @MonkeyLeeT
- CLI/Claude: keep Claude CLI session bindings stable across OAuth access-token refreshes, so gateway restarts continue the same Claude conversation instead of minting a fresh one. (#70132) Thanks @obviyus.
- QQBot: add `INTERACTION` intent (`1 << 26`) to the gateway constants and include it in the `FULL_INTENTS` mask so interaction events are received. (#70143) Thanks @cxyhhhhh.
- Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc.
- Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus.
- Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state.
- Security/dotenv: block workspace `.env` overrides for Matrix, Mattermost, IRC, and Synology endpoint settings so cloned workspaces cannot redirect bundled connector traffic through local endpoint config. (#70240) Thanks @drobison00.
- Telegram: require the same `/models` authorization for group model-picker callbacks, so unauthorized participants can no longer browse or change the session model through inline buttons. (#70235) Thanks @drobison00.
- Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.
- Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.
- OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
## 2026.4.21
@@ -279,8 +179,8 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: include requested role and runtime timing on subagent failure payloads so parent agents can correlate failed or timed-out child work. (#68726) Thanks @BKF-Gitty.
- Gateway/sessions: reject stale agent-scoped sessions after an agent is removed from config while preserving legacy default-agent main-session aliases. (#65986) Thanks @bittoby.
- Doctor/gateway: surface pending device pairing requests, scope-upgrade approval drift, and stale device-token mismatch repair steps so `openclaw doctor --fix` no longer leaves pairing/auth setup failures unexplained. (#69210) Thanks @obviyus.
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @davehappyminion and @nikilster.
- Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @davehappyminion and @nikilster.
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @obviyus.
- Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @obviyus.
- Gateway/TUI: retry session history while the local gateway is still finishing startup, so `openclaw tui` reconnects no longer fail on transient `chat.history unavailable during gateway startup` errors. (#69164) Thanks @shakkernerd.
- BlueBubbles/reactions: fall back to `love` when an agent reacts with an emoji outside the iMessage tapback set (`love`/`like`/`dislike`/`laugh`/`emphasize`/`question`), so wider-vocabulary model reactions like `👀` still produce a visible tapback instead of failing the whole reaction request. Configured ack reactions still validate strictly via the new `normalizeBlueBubblesReactionInputStrict` path. (#64693) Thanks @zqchris.
- BlueBubbles: prefer iMessage over SMS when both chats exist for the same handle, honor explicit `sms:` targets, and never silently downgrade iMessage-available recipients. (#61781) Thanks @rmartin.

View File

@@ -590,7 +590,6 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
@@ -622,7 +621,6 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
@@ -653,7 +651,6 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
@@ -686,7 +683,6 @@ public struct AgentParams: Codable, Sendable {
case timeout
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"

View File

@@ -412,13 +412,8 @@
"title": "Sessions",
"detailKeys": [
"kinds",
"label",
"agentId",
"search",
"limit",
"activeMinutes",
"includeDerivedTitles",
"includeLastMessage",
"messageLimit"
]
},

View File

@@ -590,7 +590,6 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let besteffortdeliver: Bool?
public let lane: String?
public let cleanupbundlemcponrunend: Bool?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
@@ -622,7 +621,6 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
besteffortdeliver: Bool?,
lane: String?,
cleanupbundlemcponrunend: Bool?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
@@ -653,7 +651,6 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
@@ -686,7 +683,6 @@ public struct AgentParams: Codable, Sendable {
case timeout
case besteffortdeliver = "bestEffortDeliver"
case lane
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"

View File

@@ -1,4 +1,4 @@
b05357fa162ba1f1d4ed192671b758d3905602678ff61148568840c6544d6222 config-baseline.json
a4e167f169db58d71c385a31fa2b980772f9fee963e70dd9553f63536cae5aed config-baseline.core.json
35d132fe176bd2bf9f0e46b29de91baba63ec4db3317cc5b294a982b46d16ba9 config-baseline.channel.json
3703c5345288adb9eee8cda3b592147cf4fed25a7782bed21ca83c88c3ca1cc0 config-baseline.plugin.json
3f08544c1a8143755a848aeb731f2eddf4f84cf70950c7d165f8889e01e4985d config-baseline.json
2190e81fcd754b96b48a1e012600f3b74fdb9b91eac280d8e3e038fcb73d6546 config-baseline.core.json
6c0069b971ae298ae68516ebcd3eae0e8c82820d2e8f42ecbd2f53a2f9077371 config-baseline.channel.json
9096ec947597b03f97eef44186a3102fd80ffb7f3e791fb64544464d4571448f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2b7093a57992029cc70126d33544e02eed6c3076a3a6b4ffa6aef7664da0f33d plugin-sdk-api-baseline.json
ea6a2f2326565517b6c42a4d334f615163fb434dbad5e0b8d134c92767714256 plugin-sdk-api-baseline.jsonl
ba9b9d9b321b405fef89d4e95c1a3d629d1b956398a5b2a7f25b2a7654879783 plugin-sdk-api-baseline.json
8bbbee0ea2326148d4fd49f61fe74f83c5bb24c0742cfbf3609f43939fcd4c34 plugin-sdk-api-baseline.jsonl

View File

@@ -23,14 +23,10 @@ host configuration.
## Session key shapes (examples)
Direct messages collapse to the agents **main** session by default:
Direct messages collapse to the agents **main** session:
- `agent:<agentId>:<mainKey>` (default: `agent:main:main`)
Even when direct-message conversation history is shared with main, sandbox and
tool policy use a derived per-account direct-chat runtime key for external DMs
so channel-originated messages are not treated like local main-session runs.
Groups and channels remain isolated per channel:
- Groups: `agent:<agentId>:<channel>:group:<id>`

View File

@@ -307,7 +307,7 @@ By default, components are single use. Set `components.reusable=true` to allow b
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. Unless `commands.modelsWrite=false`, `/models add` also supports adding a new provider/model entry from chat, and newly added models show up without restarting the gateway. The picker reply is ephemeral and only the invoking user can use it.
The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it.
File attachments:

View File

@@ -361,8 +361,8 @@ Surface different features that extend the above defaults.
},
{
"command": "/models",
"description": "List providers/models or add a model",
"usage_hint": "[provider] [page] [limit=<n>|size=<n>|all] | add <provider> <modelId>"
"description": "List providers or models for a provider",
"usage_hint": "[provider] [page] [limit=<n>|size=<n>|all]"
},
{
"command": "/help",

View File

@@ -12,28 +12,28 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
## Job Overview
| Job | Purpose | When it runs |
| -------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------ |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes |
| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes |
| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes |
| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `extension-fast` | Focused tests for only the changed bundled plugins | Pull requests with extension changes |
| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional` | Architecture, boundary, extension-surface guards, package-boundary, and gateway-watch shards | Node-relevant changes |
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific test lanes | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| Job | Purpose | When it runs |
| -------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-scm-fast` | Private key detection and workflow audit via `zizmor` | Always on non-draft pushes and PRs |
| `security-dependency-audit` | Dependency-free production lockfile audit against npm advisories | Always on non-draft pushes and PRs |
| `security-fast` | Required aggregate for the fast security jobs | Always on non-draft pushes and PRs |
| `build-artifacts` | Build `dist/` and the Control UI once, upload reusable artifacts for downstream jobs | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled/plugin-contract/protocol checks | Node-relevant changes |
| `checks-fast-contracts-channels` | Sharded channel contract checks with a stable aggregate check result | Node-relevant changes |
| `checks-node-extensions` | Full bundled-plugin test shards across the extension suite | Node-relevant changes |
| `checks-node-core-test` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `extension-fast` | Focused tests for only the changed bundled plugins | When extension changes are detected |
| `check` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional` | Architecture, boundary, extension-surface guards, package-boundary, and gateway-watch shards | Node-relevant changes |
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
| `checks` | Remaining Linux Node lanes: channel tests and push-only Node 22 compatibility | Node-relevant changes |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific test lanes | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android build and test matrix | Android-relevant changes |
## Fail-Fast Order
@@ -42,34 +42,27 @@ Jobs are ordered so cheap checks fail before expensive ones run:
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
2. `security-scm-fast`, `security-dependency-audit`, `security-fast`, `check`, `check-additional`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, PR-only `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-channels`, `checks-node-extensions`, `checks-node-core-test`, `extension-fast`, `checks`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke runs for install, packaging, container-relevant changes, bundled extension production changes, and the core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Test-only and docs-only edits do not reserve Docker workers. Its QR package smoke forces the Docker `pnpm install` layer to rerun while preserving the BuildKit pnpm store cache, so it still exercises installation without redownloading dependencies on every run. Its gateway-network e2e reuses the runtime image built earlier in the job, so it adds real container-to-container WebSocket coverage without adding another Docker build. A separate `docker-e2e-fast` job runs the bounded bundled-plugin Docker profile under a 120-second command timeout: setup-entry dependency repair plus synthetic bundled-loader failure isolation. The full bundled update/channel matrix remains manual/full-suite because it performs repeated real npm update and doctor repair passes.
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It computes `run_install_smoke` from the narrower changed-smoke signal, so Docker/install smoke only runs for install, packaging, and container-relevant changes.
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all lanes.
On pushes, the `checks` matrix adds the push-only `compat-node22` lane. On pull requests, that lane is skipped and the matrix stays focused on the normal test/channel lanes.
The slowest Node test families are split or balanced so each job stays small: channel contracts split registry and core coverage into six weighted shards total, bundled plugin tests balance across six extension workers, auto-reply runs as three balanced workers instead of six tiny workers, and agentic gateway/plugin configs are spread across the existing source-only agentic Node jobs instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. The broad agents lane uses the shared Vitest file-parallel scheduler because it is import/scheduling dominated rather than owned by a single slow test file. `runtime-config` runs with the infra core-runtime shard to keep the shared runtime shard from owning the tail. `check-additional` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard shard runs its small independent guards concurrently inside one job, and the gateway watch regression reuses a same-run built `dist/` and `dist-runtime/` tar artifact from `build-artifacts` so it measures watch stability without rebuilding runtime artifacts in its own worker.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest`, then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles that flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.
`extension-fast` is PR-only because push runs already execute the full bundled plugin shards. That keeps changed-plugin feedback for reviews without reserving an extra Blacksmith worker on `main` for coverage already present in `checks-node-extensions`.
The slowest Node test families are split into include-file shards so each job stays small: channel contracts split registry and core coverage into eight weighted shards each, auto-reply reply command tests split into four include-pattern shards, and the other large auto-reply reply prefix groups split into two shards each. `check-additional` also separates package-boundary compile/canary work from runtime topology gateway/architecture work.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Aggregate shard checks use `!cancelled() && always()` so they still report normal shard failures but do not queue after the whole workflow has already been superseded.
The CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. The aggregate shard checks call out this cancellation case explicitly so it is easier to distinguish from a test failure.
## Runners
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, sharded channel contract checks, `check` shards except lint, `check-additional` shards and aggregates, Node test aggregate verifiers, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-8vcpu-ubuntu-2404` | `build-artifacts`, build-smoke, Linux Node test shards, bundled plugin test shards, remaining built-artifact consumers, `android` |
| `blacksmith-16vcpu-ubuntu-2404` | `check-lint`, which remains CPU-sensitive enough that 8 vCPU cost more than it saved; install-smoke Docker builds, where 32-vCPU queue time cost more than it saved |
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-latest` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| `blacksmith-12vcpu-macos-latest` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
| Runner | Jobs |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `ubuntu-24.04` | `preflight`; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
| `blacksmith-16vcpu-ubuntu-2404` | `security-scm-fast`, `security-dependency-audit`, `security-fast`, `build-artifacts`, Linux checks, docs checks, Python skills, `android` |
| `blacksmith-32vcpu-windows-2025` | `checks-windows` |
| `blacksmith-12vcpu-macos-latest` | `macos-node`, `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
## Local Equivalents
@@ -87,5 +80,4 @@ pnpm test:channels
pnpm test:contracts:channels
pnpm check:docs # docs format + lint + broken links
pnpm build # build dist when CI artifact/build-smoke lanes matter
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
```

View File

@@ -166,11 +166,9 @@ Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
If you want ACPX-backed sessions to see OpenClaw plugin tools or selected
built-in tools such as `cron`, enable the gateway-side ACPX MCP bridges instead
of trying to pass per-session `mcpServers`. See
[ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge) and
[OpenClaw tools MCP bridge](/tools/acp-agents#openclaw-tools-mcp-bridge).
If you want ACPX-backed sessions to see OpenClaw plugin tools, enable the
gateway-side ACPX plugin bridge instead of trying to pass per-session
`mcpServers`. See [ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge).
## Use from `acpx` (Codex, Claude, other ACP clients)

View File

@@ -38,7 +38,6 @@ openclaw config get browser.executablePath
openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
openclaw config unset plugins.entries.brave.config.webSearch.apiKey
@@ -106,22 +105,6 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
`config get <path> --json` prints the raw value as JSON instead of terminal-formatted text.
Object assignment replaces the target path by default. Protected map/list paths
that commonly hold user-added entries, such as `agents.defaults.models`,
`models.providers`, `models.providers.<id>.models`, `plugins.entries`, and
`auth.profiles`, refuse replacements that would remove existing entries unless
you pass `--replace`.
Use `--merge` when adding entries to those maps:
```bash
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
openclaw config set models.providers.ollama.models '[{"id":"llama3.2","name":"Llama 3.2"}]' --strict-json --merge
```
Use `--replace` only when you intentionally want the provided value to become
the complete target value.
## `config set` modes
`openclaw config set` supports four assignment styles:
@@ -359,9 +342,6 @@ If dry-run fails:
post-change config before committing it to disk. If the new payload fails schema
validation or looks like a destructive clobber, the active config is left alone
and the rejected payload is saved beside it as `openclaw.json.rejected.*`.
The active config path must be a regular file. Symlinked `openclaw.json`
layouts are unsupported for writes; use `OPENCLAW_CONFIG_PATH` to point directly
at the real file instead.
Prefer CLI writes for small edits:
@@ -386,7 +366,7 @@ last-known-good backup during startup or hot reload. See
## Subcommands
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). The path should name a regular file, not a symlink.
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
Restart the gateway after edits.

View File

@@ -11,8 +11,6 @@ Interactive prompt to set up credentials, devices, and agent defaults.
Note: The **Model** section now includes a multi-select for the
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
Provider-scoped setup choices merge their selected models into the existing
allowlist instead of replacing unrelated providers already in the config.
When configure starts from a provider auth choice, the default-model and
allowlist pickers prefer that provider automatically. For paired providers such

View File

@@ -111,59 +111,6 @@ Options:
- `--days <days>`: number of days to include (default `30`).
### `gateway stability`
Fetch the recent diagnostic stability recorder from a running Gateway.
```bash
openclaw gateway stability
openclaw gateway stability --type payload.large
openclaw gateway stability --bundle latest
openclaw gateway stability --bundle latest --export
openclaw gateway stability --json
```
Options:
- `--limit <limit>`: maximum number of recent events to include (default `25`, max `1000`).
- `--type <type>`: filter by diagnostic event type, such as `payload.large` or `diagnostic.memory.pressure`.
- `--since-seq <seq>`: include only events after a diagnostic sequence number.
- `--bundle [path]`: read a persisted stability bundle instead of calling the running Gateway. Use `--bundle latest` (or just `--bundle`) for the newest bundle under the state directory, or pass a bundle JSON path directly.
- `--export`: write a shareable support diagnostics zip instead of printing stability details.
- `--output <path>`: output path for `--export`.
Notes:
- The recorder is active by default. Set `diagnostics.enabled: false` only when you need to disable Gateway diagnostic heartbeat collection.
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids.
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
### `gateway diagnostics export`
Write a local diagnostics zip that is designed to attach to bug reports.
```bash
openclaw gateway diagnostics export
openclaw gateway diagnostics export --output openclaw-diagnostics.zip
openclaw gateway diagnostics export --json
```
Options:
- `--output <path>`: output zip path. Defaults to a support export under the state directory.
- `--log-lines <count>`: maximum sanitized log lines to include (default `5000`).
- `--log-bytes <bytes>`: maximum log bytes to inspect (default `1000000`).
- `--url <url>`: Gateway WebSocket URL for the health snapshot.
- `--token <token>`: Gateway token for the health snapshot.
- `--password <password>`: Gateway password for the health snapshot.
- `--timeout <ms>`: status/health snapshot timeout (default `3000`).
- `--no-stability-bundle`: skip persisted stability bundle lookup.
- `--json`: print the written path, size, and manifest as JSON.
The export contains a manifest, a Markdown summary, config shape, sanitized config details, sanitized log summaries, sanitized Gateway status/health snapshots, and the newest stability bundle when one exists.
It is meant to be shared. It keeps operational details that help debugging, such as safe OpenClaw log fields, subsystem names, status codes, durations, configured modes, ports, plugin ids, provider ids, non-secret feature settings, and redacted operational log messages. It omits or redacts chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, prompt/instruction text, hostnames, and secret values. When a LogTape-style message looks like user/chat/tool payload text, the export keeps only that a message was omitted plus its byte count.
### `gateway status`
`gateway status` shows the Gateway service (launchd/systemd/schtasks) plus an optional probe of connectivity/auth capability.

View File

@@ -369,9 +369,6 @@ Important behavior:
reachable right now
- runtime adapters decide which transport shapes they actually support at
execution time
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
disables them explicitly
## Saved MCP server definitions

View File

@@ -33,7 +33,7 @@ openclaw plugins enable <id>
openclaw plugins disable <id>
openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id-or-npm-spec>
openclaw plugins update <id>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
@@ -76,8 +76,6 @@ bundled-plugin recovery path for plugins that explicitly opt into
`--force` reuses the existing install target and overwrites an already-installed
plugin or hook pack in place. Use it when you are intentionally reinstalling
the same id from a new local path, archive, ClawHub package, or npm artifact.
For routine upgrades of an already tracked npm plugin, prefer
`openclaw plugins update <id-or-npm-spec>`.
`--pin` applies to npm installs only. It is not supported with `--marketplace`,
because marketplace installs persist marketplace source metadata instead of an
@@ -245,20 +243,9 @@ or exact version. OpenClaw resolves that package name back to the tracked plugin
record, updates that installed plugin, and records the new npm spec for future
id-based updates.
Passing the npm package name without a version or tag also resolves back to the
tracked plugin record. Use this when a plugin was pinned to an exact version and
you want to move it back to the registry's default release line.
Before a live npm update, OpenClaw checks the installed package version against
the npm registry metadata. If the installed version and recorded artifact
identity already match the resolved target, the update is skipped without
downloading, reinstalling, or rewriting `openclaw.json`.
When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw treats that as npm artifact drift. The interactive
`openclaw plugins update` command prints the expected and actual hashes and asks
for confirmation before proceeding. Non-interactive update helpers fail closed
unless the caller supplies an explicit continuation policy.
OpenClaw prints a warning and asks for confirmation before proceeding. Use
global `--yes` to bypass prompts in CI/non-interactive runs.
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
break-glass override for built-in dangerous-code scan false positives during
@@ -305,10 +292,6 @@ openclaw plugins doctor
compatibility notices. When everything is clean it prints `No plugin issues
detected.`
For module-shape failures such as missing `register`/`activate` exports, rerun
with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in
the diagnostic output.
### Marketplace
```bash

View File

@@ -36,9 +36,7 @@ openclaw --update
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
- `--json`: print machine-readable `UpdateRunResult` JSON, including
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
detected during post-update plugin sync.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation)
@@ -82,12 +80,6 @@ install method aligned:
The Gateway core auto-updater (when enabled via config) reuses this same update path.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. If the installed version exactly
matches the target and no update-channel change needs to be persisted, the
command exits as skipped before package install, plugin sync, completion refresh,
or gateway restart work.
## Git checkout flow
Channels:
@@ -109,11 +101,6 @@ High-level:
8. Runs `openclaw doctor` as the final “safe update” check.
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
If an exact pinned npm plugin update resolves to an artifact whose integrity
differs from the stored install record, `openclaw update` aborts that plugin
artifact update instead of installing it. Reinstall or update the plugin
explicitly only after verifying that you trust the new artifact.
If pnpm bootstrap still fails, the updater now stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout.
## `--update` shorthand

View File

@@ -167,10 +167,6 @@ Defaults live under `agents.defaults.silentReply` and
`agents.defaults.silentReplyRewrite`; `surfaces.<id>.silentReply` and
`surfaces.<id>.silentReplyRewrite` can override them per surface.
When the parent session has one or more pending spawned subagent runs, bare
silent replies are dropped on all surfaces instead of being rewritten, so the
parent stays quiet until the child completion event delivers the real reply.
## Related
- [Streaming](/concepts/streaming) — real-time message delivery

View File

@@ -67,24 +67,6 @@ to `zai/*`.
Provider configuration examples (including OpenCode) live in
[/providers/opencode](/providers/opencode).
### Safe allowlist edits
Use additive writes when updating `agents.defaults.models` by hand:
```bash
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
```
`openclaw config set` protects model/provider maps from accidental clobbers. A
plain object assignment to `agents.defaults.models`, `models.providers`, or
`models.providers.<id>.models` is rejected when it would remove existing
entries. Use `--merge` for additive changes; use `--replace` only when the
provided value should become the complete target value.
Interactive provider setup and `openclaw configure --section model` also merge
provider-scoped selections into the existing allowlist, so adding Codex,
Ollama, or another provider does not drop unrelated model entries.
## "Model is not allowed" (and why replies stop)
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for
@@ -132,9 +114,6 @@ Notes:
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step.
- `/models add` is available by default and can be disabled with `commands.modelsWrite=false`.
- When enabled, `/models add <provider> <modelId>` is the fastest path; bare `/models add` starts a provider-first guided flow where supported.
- After `/models add`, the new model becomes available in `/models` and `/model` without restarting the gateway.
- `/model <#>` selects from that picker.
- `/model` persists the new session selection immediately.
- If the agent is idle, the next run uses the new model right away.
@@ -153,14 +132,6 @@ Notes:
Full command behavior/config: [Slash commands](/tools/slash-commands).
Examples:
```text
/models add
/models add ollama glm-5.1:cloud
/models add lmstudio qwen/qwen3.5-9b
```
## CLI commands
```bash

View File

@@ -16,7 +16,7 @@ orchestrate sub-agents.
| Tool | What it does |
| ------------------ | --------------------------------------------------------------------------- |
| `sessions_list` | List sessions with optional filters (kind, label, agent, recency, preview) |
| `sessions_list` | List sessions with optional filters (kind, recency) |
| `sessions_history` | Read the transcript of a specific session |
| `sessions_send` | Send a message to another session and optionally wait |
| `sessions_spawn` | Spawn an isolated sub-agent session for background work |
@@ -26,13 +26,9 @@ orchestrate sub-agents.
## Listing and reading sessions
`sessions_list` returns sessions with their key, agentId, kind, channel, model,
token counts, and timestamps. Filter by kind (`main`, `group`, `cron`, `hook`,
`node`), exact `label`, exact `agentId`, search text, or recency
(`activeMinutes`). When you need mailbox-style triage, it can also ask for
derived titles, last-message previews, or bounded recent messages. Preview
transcript reads are scoped to sessions visible under the configured session
tool visibility policy.
`sessions_list` returns sessions with their key, kind, channel, model, token
counts, and timestamps. Filter by kind (`main`, `group`, `cron`, `hook`,
`node`) or recency (`activeMinutes`).
`sessions_history` fetches the conversation transcript for a specific session.
By default, tool results are excluded -- pass `includeTools: true` to see them.

View File

@@ -181,10 +181,10 @@ child process environment for the run.
- `always`: always send a session id (new UUID if none stored).
- `existing`: only send a session id if one was stored before.
- `none`: never send a session id.
- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`,
and `input: "stdin"` so follow-up turns reuse the live Claude process while
it is active. If the Gateway restarts or the idle process exits, OpenClaw
resumes from the stored Claude session id.
- The bundled `claude-cli` backend uses `liveSession: "claude-stdio"` so
follow-up turns reuse the live Claude process while it is active. If the
Gateway restarts or the idle process exits, OpenClaw resumes from the stored
Claude session id.
- Stored CLI sessions are provider-owned continuity. The implicit daily session
reset does not cut them; `/reset` and explicit `session.reset` policies still
do.

View File

@@ -1238,8 +1238,6 @@ Time format in system prompt. Default: `auto` (OS preference).
- `elevatedDefault`: default elevated-output level for agents. Values: `"off"`, `"on"`, `"ask"`, `"full"`. Default: `"on"`.
- `model.primary`: format `provider/model` (e.g. `openai/gpt-5.4`). If you omit the provider, OpenClaw tries an alias first, then a unique configured-provider match for that exact model id, and only then falls back to the configured default provider (deprecated compatibility behavior, so prefer explicit `provider/model`). If that provider no longer exposes the configured default model, OpenClaw falls back to the first configured provider/model instead of surfacing a stale removed-provider default.
- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`).
- Safe edits: use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing allowlist entries unless you pass `--replace`.
- Provider-scoped configure/onboarding flows merge selected provider models into this map and preserve unrelated providers already configured.
- `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`).
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details.
- `embeddedHarness`: default low-level embedded agent runtime policy. Use `runtime: "auto"` to let registered plugin harnesses claim supported models, `runtime: "pi"` to force the built-in PI harness, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback.
@@ -1340,28 +1338,6 @@ Replace the entire OpenClaw-assembled system prompt with a fixed string. Set at
}
```
### `agents.defaults.promptOverlays`
Provider-independent prompt overlays applied by model family. GPT-5-family model ids receive the shared behavior contract across providers; `personality` controls only the friendly interaction-style layer.
```json5
{
agents: {
defaults: {
promptOverlays: {
gpt5: {
personality: "friendly", // friendly | on | off
},
},
},
},
}
```
- `"friendly"` (default) and `"on"` enable the friendly interaction-style layer.
- `"off"` disables only the friendly layer; the tagged GPT-5 behavior contract remains enabled.
- Legacy `plugins.entries.openai.config.personality` is still read when this shared setting is unset.
### `agents.defaults.heartbeat`
Periodic heartbeat runs.
@@ -2586,7 +2562,6 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
- `models.mode`: provider catalog behavior (`merge` or `replace`).
- `models.providers`: custom provider map keyed by provider id.
- Safe edits: use `openclaw config set models.providers.<id> '<json>' --strict-json --merge` or `openclaw config set models.providers.<id>.models '<json-array>' --strict-json --merge` for additive updates. `config set` refuses destructive replacements unless you pass `--replace`.
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
@@ -3903,8 +3878,6 @@ Split config into multiple files:
- Sibling keys: merged after includes (override included values).
- Nested includes: up to 10 levels deep.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
- OpenClaw-owned writes that change only one top-level section backed by a single-file include write through to that included file. For example, `plugins install` updates `plugins: { $include: "./plugins.json5" }` in `plugins.json5` and leaves `openclaw.json` intact.
- Root includes, include arrays, and includes with sibling overrides are read-only for OpenClaw-owned writes; those writes fail closed instead of flattening the config.
- Errors: clear messages for missing files, parse errors, and circular includes.
---

View File

@@ -10,10 +10,6 @@ title: "Configuration"
# Configuration
OpenClaw reads an optional <Tooltip tip="JSON5 supports comments and trailing commas">**JSON5**</Tooltip> config from `~/.openclaw/openclaw.json`.
The active config path must be a regular file. Symlinked `openclaw.json`
layouts are unsupported for OpenClaw-owned writes; an atomic write may replace
the path instead of preserving the symlink. If you keep config outside the
default state directory, point `OPENCLAW_CONFIG_PATH` directly at the real file.
If the file is missing, OpenClaw uses safe defaults. Common reasons to add a config:
@@ -104,13 +100,6 @@ The Gateway also keeps a trusted last-known-good copy after a successful startup
`openclaw.json` is later changed outside OpenClaw and no longer validates, startup
and hot reload preserve the broken file as a timestamped `.clobbered.*` snapshot,
restore the last-known-good copy, and log a loud warning with the recovery reason.
Startup read recovery also treats sharp size drops, missing config metadata, and a
missing `gateway.mode` as critical clobber signatures when the last-known-good
copy had those fields.
If a status/log line is accidentally prepended before an otherwise valid JSON
config, gateway startup and `openclaw doctor --fix` can strip the prefix,
preserve the polluted file as `.clobbered.*`, and continue with the recovered
JSON.
The next main-agent turn also receives a system-event warning telling it that the
config was restored and must not be blindly rewritten. Last-known-good promotion
is updated after validated startup and after accepted hot reloads, including
@@ -173,7 +162,6 @@ placeholders such as `***` or shortened token values.
```
- `agents.defaults.models` defines the model catalog and acts as the allowlist for `/model`.
- Use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add allowlist entries without removing existing models. Plain replacements that would remove entries are rejected unless you pass `--replace`.
- Model refs use `provider/model` format (e.g. `anthropic/claude-opus-4-6`).
- `agents.defaults.imageMaxDimensionPx` controls transcript/tool image downscaling (default `1200`); lower values usually reduce vision-token usage on screenshot-heavy runs.
- See [Models CLI](/concepts/models) for switching models in chat and [Model Failover](/concepts/model-failover) for auth rotation and fallback behavior.
@@ -508,12 +496,6 @@ placeholders such as `***` or shortened token values.
- **Sibling keys**: merged after includes (override included values)
- **Nested includes**: supported up to 10 levels deep
- **Relative paths**: resolved relative to the including file
- **OpenClaw-owned writes**: when a write changes only one top-level section
backed by a single-file include such as `plugins: { $include: "./plugins.json5" }`,
OpenClaw updates that included file and leaves `openclaw.json` intact
- **Unsupported write-through**: root includes, include arrays, and includes
with sibling overrides fail closed for OpenClaw-owned writes instead of
flattening the config
- **Error handling**: clear errors for missing files, parse errors, and circular includes
</Accordion>

View File

@@ -26,8 +26,6 @@ Short guide to verify channel connectivity without guessing.
- Creds on disk: `ls -l ~/.openclaw/credentials/whatsapp/<accountId>/creds.json` (mtime should be recent).
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
- Diagnostics are enabled by default. The gateway records operational facts unless `diagnostics.enabled: false` is set. Memory events record RSS/heap byte counts, threshold pressure, and growth pressure. Oversized-payload events record what was rejected, truncated, or chunked, plus sizes and limits when available. They do not record the message text, attachment contents, webhook body, raw request or response body, tokens, cookies, or secret values. The same heartbeat starts the bounded stability recorder, which is available through `openclaw gateway stability` or the `diagnostics.stability` Gateway RPC. Fatal Gateway exits, shutdown timeouts, and restart startup failures persist the latest recorder snapshot under `~/.openclaw/logs/stability/` when events exist; inspect the newest saved bundle with `openclaw gateway stability --bundle latest`.
- For bug reports, run `openclaw gateway diagnostics export` and attach the generated zip. The export combines a Markdown summary, the newest stability bundle, sanitized log metadata, sanitized Gateway status/health snapshots, and config shape. It is meant to be shared: chat text, webhook bodies, tool outputs, credentials, cookies, account/message identifiers, and secret values are omitted or redacted.
## Health monitor config

View File

@@ -18,13 +18,6 @@ handshake time.
- WebSocket, text frames with JSON payloads.
- First frame **must** be a `connect` request.
- Pre-connect frames are capped at 64 KiB. After a successful handshake, clients
should follow the `hello-ok.policy.maxPayload` and
`hello-ok.policy.maxBufferedBytes` limits. With diagnostics enabled,
oversized inbound frames and slow outbound buffers emit `payload.large` events
before the gateway closes or drops the affected frame. These events keep
sizes, limits, surfaces, and safe reason codes. They do not keep the message
body, attachment contents, raw frame body, tokens, cookies, or secret values.
## Handshake (connect)
@@ -272,12 +265,6 @@ implemented in `src/gateway/server-methods/*.ts`.
### System and identity
- `health` returns the cached or freshly probed gateway health snapshot.
- `diagnostics.stability` returns the recent bounded diagnostic stability
recorder. It keeps operational metadata such as event names, counts, byte
sizes, memory readings, queue/session state, channel/plugin names, and session
ids. It does not keep chat text, webhook bodies, tool outputs, raw request or
response bodies, tokens, cookies, or secret values. Operator read scope is
required.
- `status` returns the `/status`-style gateway summary; sensitive fields are
included only for admin-scoped operator clients.
- `gateway.identity.get` returns the gateway device identity used by relay and

View File

@@ -247,7 +247,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e
| `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes |
| `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no |
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no |
| `fs.config.symlink` | warn | Config target becomes another trust boundary | config file filesystem layout | no |
| `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes |
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes |

View File

@@ -303,7 +303,6 @@ Common signatures:
- `.clobbered.*` exists → an external direct edit or startup read was restored.
- `.rejected.*` exists → an OpenClaw-owned config write failed schema or clobber checks before commit.
- `Config write rejected:` → the write tried to drop required shape, shrink the file sharply, or persist invalid config.
- `missing-meta-vs-last-good`, `gateway-mode-missing-vs-last-good`, or `size-drop-vs-last-good:*` → startup treated the current file as clobbered because it lost fields or size compared with the last-known-good backup.
- `Config last-known-good promotion skipped` → the candidate contained redacted secret placeholders such as `***`.
Fix options:
@@ -476,7 +475,7 @@ Common signatures:
- `No Chrome tabs found for profile="user"` → the Chrome MCP attach profile has no open local Chrome tabs.
- `Remote CDP for profile "<name>" is not reachable` → the configured remote CDP endpoint is not reachable from the gateway host.
- `Browser attachOnly is enabled ... not reachable` or `Browser attachOnly is enabled and CDP websocket ... is not reachable` → attach-only profile has no reachable target, or the HTTP endpoint answered but the CDP WebSocket still could not be opened.
- `Playwright is not available in this gateway build; '<feature>' is unsupported.` → the current gateway install lacks the bundled browser plugin's `playwright-core` runtime dependency; run `openclaw doctor --fix`, then restart the gateway. ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable.
- `Playwright is not available in this gateway build; '<feature>' is unsupported.` → the current gateway install lacks the full Playwright package; ARIA snapshots and basic page screenshots can still work, but navigation, AI snapshots, CSS-selector element screenshots, and PDF export stay unavailable.
- `fullPage is not supported for element screenshots` → screenshot request mixed `--full-page` with `--ref` or `--element`.
- `element screenshots are not supported for existing-session profiles; use ref from snapshot.` → Chrome MCP / `existing-session` screenshot calls must use page capture or a snapshot `--ref`, not CSS `--element`.
- `existing-session file uploads do not support element selectors; use ref/inputRef.` → Chrome MCP upload hooks need snapshot refs, not CSS selectors.

View File

@@ -329,20 +329,6 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- `pnpm test:perf:profile:main` writes a main-thread CPU profile for Vitest/Vite startup and transform overhead.
- `pnpm test:perf:profile:runner` writes runner CPU+heap profiles for the unit suite with file parallelism disabled.
### Stability (gateway)
- Command: `pnpm test:stability:gateway`
- Config: `vitest.gateway.config.ts`, forced to one worker
- Scope:
- Starts a real loopback Gateway with diagnostics enabled by default
- Drives synthetic gateway message, memory, and large-payload churn through the diagnostic event path
- Queries `diagnostics.stability` over the Gateway WS RPC
- Covers diagnostic stability bundle persistence helpers
- Asserts the recorder remains bounded, synthetic RSS samples stay under the pressure budget, and per-session queue depths drain back to zero
- Expectations:
- CI-safe and keyless
- Narrow lane for stability-regression follow-up, not a substitute for the full Gateway suite
### E2E (gateway smoke)
- Command: `pnpm test:e2e`
@@ -622,15 +608,11 @@ Docker notes:
thread can resume
- run `/codex status` and `/codex models` through the same gateway command
path
- optionally run two Guardian-reviewed escalated shell probes: one benign
command that should be approved and one fake-secret upload that should be
denied so the agent asks back
- Test: `src/gateway/gateway-codex-harness.live.test.ts`
- Enable: `OPENCLAW_LIVE_CODEX_HARNESS=1`
- Default model: `codex/gpt-5.4`
- Optional image probe: `OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1`
- Optional MCP/tool probe: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1`
- Optional Guardian probe: `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1`
- The smoke sets `OPENCLAW_AGENT_HARNESS_FALLBACK=none` so a broken Codex
harness cannot pass by silently falling back to PI.
- Auth: `OPENAI_API_KEY` from the shell/profile, plus optional copied
@@ -643,7 +625,6 @@ source ~/.profile
OPENCLAW_LIVE_CODEX_HARNESS=1 \
OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1 \
OPENCLAW_LIVE_CODEX_HARNESS_MODEL=codex/gpt-5.4 \
pnpm test:live -- src/gateway/gateway-codex-harness.live.test.ts
```
@@ -661,11 +642,9 @@ Docker notes:
- It sources the mounted `~/.profile`, passes `OPENAI_API_KEY`, copies Codex CLI
auth files when present, installs `@openai/codex` into a writable mounted npm
prefix, stages the source tree, then runs only the Codex-harness live test.
- Docker enables the image, MCP/tool, and Guardian probes by default. Set
- Docker enables the image and MCP/tool probes by default. Set
`OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=0` or
`OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0` or
`OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=0` when you need a narrower debug
run.
`OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0` when you need a narrower debug run.
- Docker also exports `OPENCLAW_AGENT_HARNESS_FALLBACK=none`, matching the live
test config so `openai-codex/*` or PI fallback cannot hide a Codex harness
regression.
@@ -802,11 +781,10 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Current bundled providers covered:
- `openai`
- `google`
- `xai`
- Optional narrowing:
- `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google,xai"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-2,google/gemini-3.1-flash-image-preview,xai/grok-imagine-image"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit,xai:default-generate,xai:default-edit"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-2,google/gemini-3.1-flash-image-preview"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
@@ -896,7 +874,7 @@ These Docker runners split into two buckets:
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
explicitly want the larger exhaustive scan.
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
@@ -909,12 +887,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
- MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`)
- Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`)
- Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`)
- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
- Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`.
- Narrow bundled plugin runtime deps while iterating by disabling unrelated scenarios, for example:
`OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=0 pnpm test:docker:bundled-channel-deps`.
The live-model Docker runners also bind-mount the current checkout read-only and
stage it into a temporary workdir inside the container. This keeps the runtime
@@ -947,15 +920,6 @@ live event queue behavior, outbound send routing, and Claude-style channel +
permission notifications over the real stdio MCP bridge. The notification check
inspects the raw stdio MCP frames directly so the smoke validates what the
bridge actually emits, not just what a specific client SDK happens to surface.
`test:docker:pi-bundle-mcp-tools` is deterministic and does not need a live
model key. It builds the repo Docker image, starts a real stdio MCP probe server
inside the container, materializes that server through the embedded Pi bundle
MCP runtime, executes the tool, then verifies `coding` and `messaging` keep
`bundle-mcp` tools while `minimal` and `tools.deny: ["bundle-mcp"]` filter them.
`test:docker:cron-mcp-cleanup` is deterministic and does not need a live model
key. It starts a seeded Gateway with a real stdio MCP probe server, runs an
isolated cron turn and a `/subagents spawn` one-shot child turn, then verifies
the MCP child process exits after each run.
Manual ACP plain-language thread smoke (not CI):

View File

@@ -164,7 +164,7 @@ working option**:
example through `agents.defaults.imageModel` or
`openclaw infer image describe --model ollama/<vision-model>`.
- Bundled fallback order:
- Audio: OpenAI → Groq → xAI → Deepgram → Google → Mistral
- Audio: OpenAI → Groq → Deepgram → Google → Mistral
- Image: OpenAI → Anthropic → Google → MiniMax → MiniMax Portal → Z.AI
- Video: Google → Qwen → Moonshot
@@ -212,7 +212,6 @@ lists, OpenClaw can infer defaults:
- `mistral`: **audio**
- `zai`: **image**
- `groq`: **audio**
- `xai`: **audio**
- `deepgram`: **audio**
- Any `models.providers.<id>.models[]` catalog with an image-capable model:
**image**

View File

@@ -190,8 +190,6 @@ Hook guard semantics to keep in mind:
- `before_install`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
- `message_received`: prefer the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: prefer typed `replyToId` / `threadId` routing fields over channel-specific metadata keys.
The `/approve` command handles both exec and plugin approvals with bounded fallback: when an exec approval id is not found, OpenClaw retries the same id through plugin approvals. Plugin approval forwarding can be configured independently via `approvals.plugin` in config.

View File

@@ -104,8 +104,6 @@ loader. Cursor command markdown works through the same path.
`mcpServers`
- OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by
launching stdio servers or connecting to HTTP servers
- the `coding` and `messaging` tool profiles include bundle MCP tools by
default; use `tools.deny: ["bundle-mcp"]` to opt out for an agent or gateway
- project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed
- bundle MCP tool catalogs are sorted deterministically before registration, so
@@ -172,9 +170,6 @@ OpenClaw registers bundle MCP tools with provider-safe names in the form
- colliding sanitized names are disambiguated with numeric suffixes
- final exposed tool order is deterministic by safe name to keep repeated Pi
turns cache-stable
- profile filtering treats all tools from one bundle MCP server as plugin-owned
by `bundle-mcp`, so profile allowlists and deny lists can include either
individual exposed tool names or the `bundle-mcp` plugin key
#### Embedded Pi settings

View File

@@ -17,14 +17,6 @@ discovery, native thread resume, native compaction, and app-server execution.
OpenClaw still owns chat channels, session files, model selection, tools,
approvals, media delivery, and the visible transcript mirror.
Native Codex turns also respect the shared `before_prompt_build`,
`before_compaction`, and `after_compaction` plugin hooks, so prompt shims and
compaction-aware automation can stay aligned with the PI harness.
Native Codex turns also respect the shared `before_prompt_build`,
`before_compaction`, `after_compaction`, `llm_input`, `llm_output`, and
`agent_end` plugin hooks, so prompt shims, compaction-aware automation, and
lifecycle observers can stay aligned with the PI harness.
The harness is off by default. It is selected only when the `codex` plugin is
enabled and the resolved model is a `codex/*` model, or when you explicitly
force `embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
@@ -271,14 +263,12 @@ By default, the plugin starts Codex locally with:
codex app-server --listen stdio://
```
By default, OpenClaw starts local Codex harness sessions in YOLO mode:
`approvalPolicy: "never"`, `approvalsReviewer: "user"`, and
`sandbox: "danger-full-access"`. This is the trusted local operator posture used
for autonomous heartbeats: Codex can use shell and network tools without
stopping on native approval prompts that nobody is around to answer.
To opt in to Codex guardian-reviewed approvals, set `appServer.mode:
"guardian"`:
By default, OpenClaw starts local Codex harness sessions fully unchained:
`approvalPolicy: "never"` and `sandbox: "danger-full-access"`. That matches the
trusted local operator posture used by the Codex CLI and lets autonomous
heartbeats use network and shell tools without waiting on an invisible native
approval path. You can tighten that policy, for example by routing reviews
through the guardian:
```json5
{
@@ -288,30 +278,10 @@ To opt in to Codex guardian-reviewed approvals, set `appServer.mode:
enabled: true,
config: {
appServer: {
mode: "guardian",
serviceTier: "fast",
},
},
},
},
},
}
```
Guardian mode expands to:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
mode: "guardian",
approvalPolicy: "on-request",
approvalPolicy: "untrusted",
approvalsReviewer: "guardian_subagent",
sandbox: "workspace-write",
serviceTier: "priority",
},
},
},
@@ -320,23 +290,6 @@ Guardian mode expands to:
}
```
Guardian is a native Codex approval reviewer. When Codex asks to leave the
sandbox, write outside the workspace, or add permissions such as network access,
Codex routes that approval request to a reviewer subagent instead of a human
prompt. The reviewer gathers context and applies Codex's risk framework, then
approves or denies the specific request. Guardian is useful when you want more
guardrails than YOLO mode but still need unattended agents and heartbeats to
make progress.
The Docker live harness includes a Guardian probe when
`OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1`. It starts the Codex harness in
Guardian mode, verifies that a benign escalated shell command is approved, and
verifies that a fake-secret upload to an untrusted external destination is
denied so the agent asks back for explicit approval.
The individual policy fields still win over `mode`, so advanced deployments can
mix the preset with explicit choices.
For an already-running app-server, use WebSocket transport:
```json5
@@ -361,35 +314,30 @@ For an already-running app-server, use WebSocket transport:
Supported `appServer` fields:
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | `"codex"` | Executable for stdio transport. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex Guardian review prompts. |
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | `"codex"` | Executable for stdio transport. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex guardian review native approvals. |
| `serviceTier` | unset | Optional Codex service tier, for example `"priority"`. |
The older environment variables still work as fallbacks for local testing when
the matching config field is unset:
- `OPENCLAW_CODEX_APP_SERVER_BIN`
- `OPENCLAW_CODEX_APP_SERVER_ARGS`
- `OPENCLAW_CODEX_APP_SERVER_MODE=yolo|guardian`
- `OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY`
- `OPENCLAW_CODEX_APP_SERVER_SANDBOX`
- `OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1`
`OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1` was removed. Use
`plugins.entries.codex.config.appServer.mode: "guardian"` instead, or
`OPENCLAW_CODEX_APP_SERVER_MODE=guardian` for one-off local testing. Config is
preferred for repeatable deployments because it keeps the plugin behavior in the
same reviewed file as the rest of the Codex harness setup.
Config is preferred for repeatable deployments.
## Common recipes
@@ -434,7 +382,6 @@ Guardian-reviewed Codex approvals:
enabled: true,
config: {
appServer: {
mode: "guardian",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "workspace-write",
@@ -513,10 +460,7 @@ When the selected model uses the Codex harness, native thread compaction is
delegated to Codex app-server. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching. The
mirror includes the user prompt, final assistant text, and lightweight Codex
reasoning or plan records when the app-server emits them. Today, OpenClaw only
records native compaction start and completion signals. It does not yet expose a
human-readable compaction summary or an auditable list of which entries Codex
kept after compaction.
reasoning or plan records when the app-server emits them.
Media generation does not require PI. Image, video, music, PDF, TTS, and media
understanding continue to use the matching provider/model settings such as

View File

@@ -134,15 +134,6 @@ OpenClaw requires Codex app-server `0.118.0` or newer. The Codex plugin checks
the app-server initialize handshake and blocks older or unversioned servers so
OpenClaw only runs against the protocol surface it has been tested with.
### Codex app-server tool-result middleware
Bundled plugins can also attach Codex app-server-specific `tool_result`
middleware through `api.registerCodexAppServerExtensionFactory(...)` when their
manifest declares `contracts.embeddedExtensionFactories: ["codex-app-server"]`.
This is the trusted-plugin seam for async tool-result transforms that need to
run inside the native Codex harness before the tool output is projected back
into the OpenClaw transcript.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -192,7 +192,7 @@ explicitly promotes one as public.
| `plugin-sdk/process-runtime` | Process exec helpers |
| `plugin-sdk/cli-runtime` | CLI formatting, wait, and version helpers |
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers and plugin-config lookup helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
@@ -460,9 +460,6 @@ AI CLI backend such as `codex-cli`.
- `reply_dispatch`: returning `{ handled: true, ... }` is terminal. Once any handler claims dispatch, lower-priority handlers and the default model dispatch path are skipped.
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
- `message_received`: use the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
- `message_sending`: use typed `replyToId` / `threadId` routing fields before falling back to channel-specific `metadata`.
- `gateway_start`: use `ctx.config`, `ctx.workspaceDir`, and `ctx.getCron?.()` for gateway-owned startup state instead of relying on internal `gateway:startup` hooks.
### API object fields

View File

@@ -155,8 +155,8 @@ Current runtime behavior:
- `streaming.provider` is optional. If unset, Voice Call uses the first
registered realtime transcription provider.
- Bundled realtime transcription providers include OpenAI (`openai`) and xAI
(`xai`), registered by their provider plugins.
- Today the bundled provider is OpenAI, registered by the bundled `openai`
plugin.
- Provider-owned raw config lives under `streaming.providers.<providerId>`.
- If `streaming.provider` points at an unregistered provider, or no realtime
transcription provider is registered at all, Voice Call logs a warning and
@@ -169,15 +169,6 @@ OpenAI streaming transcription defaults:
- `silenceDurationMs`: `800`
- `vadThreshold`: `0.5`
xAI streaming transcription defaults:
- API key: `streaming.providers.xai.apiKey` or `XAI_API_KEY`
- endpoint: `wss://api.x.ai/v1/stt`
- `encoding`: `mulaw`
- `sampleRate`: `8000`
- `endpointingMs`: `800`
- `interimResults`: `true`
Example:
```json5
@@ -206,33 +197,6 @@ Example:
}
```
Use xAI instead:
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "xai",
streamPath: "/voice/stream",
providers: {
xai: {
apiKey: "${XAI_API_KEY}", // optional if XAI_API_KEY is set
endpointingMs: 800,
language: "en",
},
},
},
},
},
},
},
}
```
Legacy keys are still auto-migrated by `openclaw doctor --fix`:
- `streaming.sttProvider``streaming.provider`

View File

@@ -2,22 +2,18 @@
summary: "Deepgram transcription for inbound voice notes"
read_when:
- You want Deepgram speech-to-text for audio attachments
- You want Deepgram streaming transcription for Voice Call
- You need a quick Deepgram config example
title: "Deepgram"
---
# Deepgram (Audio Transcription)
Deepgram is a speech-to-text API. In OpenClaw it is used for inbound
audio/voice-note transcription through `tools.media.audio` and for Voice Call
streaming STT through `plugins.entries.voice-call.config.streaming`.
Deepgram is a speech-to-text API. In OpenClaw it is used for **inbound audio/voice note
transcription** via `tools.media.audio`.
For batch transcription, OpenClaw uploads the complete audio file to Deepgram
and injects the transcript into the reply pipeline (`{{Transcript}}` +
`[Audio]` block). For Voice Call streaming, OpenClaw forwards live G.711
u-law frames over Deepgram's WebSocket `listen` endpoint and emits partial or
final transcripts as Deepgram returns them.
When enabled, OpenClaw uploads the audio file to Deepgram and injects the transcript
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
it uses the pre-recorded transcription endpoint.
| Detail | Value |
| ------------- | ---------------------------------------------------------- |
@@ -105,52 +101,6 @@ final transcripts as Deepgram returns them.
</Tab>
</Tabs>
## Voice Call streaming STT
The bundled `deepgram` plugin also registers a realtime transcription provider
for the Voice Call plugin.
| Setting | Config path | Default |
| --------------- | ----------------------------------------------------------------------- | -------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` |
| Model | `...deepgram.model` | `nova-3` |
| Language | `...deepgram.language` | (unset) |
| Encoding | `...deepgram.encoding` | `mulaw` |
| Sample rate | `...deepgram.sampleRate` | `8000` |
| Endpointing | `...deepgram.endpointingMs` | `800` |
| Interim results | `...deepgram.interimResults` | `true` |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "deepgram",
providers: {
deepgram: {
apiKey: "${DEEPGRAM_API_KEY}",
model: "nova-3",
endpointingMs: 800,
language: "en-US",
},
},
},
},
},
},
},
}
```
<Note>
Voice Call receives telephony audio as 8 kHz G.711 u-law. The Deepgram
streaming provider defaults to `encoding: "mulaw"` and `sampleRate: 8000`, so
Twilio media frames can be forwarded directly.
</Note>
## Notes
<AccordionGroup>
@@ -168,6 +118,12 @@ Twilio media frames can be forwarded directly.
</Accordion>
</AccordionGroup>
<Note>
Deepgram transcription is **pre-recorded only** (not real-time streaming). OpenClaw
uploads the complete audio file and waits for the full transcript before injecting
it into the conversation.
</Note>
## Related
<CardGroup cols={2}>

View File

@@ -1,111 +0,0 @@
---
summary: "Use ElevenLabs speech, Scribe STT, and realtime transcription with OpenClaw"
read_when:
- You want ElevenLabs text-to-speech in OpenClaw
- You want ElevenLabs Scribe speech-to-text for audio attachments
- You want ElevenLabs realtime transcription for Voice Call
title: "ElevenLabs"
---
# ElevenLabs
OpenClaw uses ElevenLabs for text-to-speech, batch speech-to-text with Scribe
v2, and Voice Call streaming STT with Scribe v2 Realtime.
| Capability | OpenClaw surface | Default |
| ------------------------ | --------------------------------------------- | ------------------------ |
| Text-to-speech | `messages.tts` / `talk` | `eleven_multilingual_v2` |
| Batch speech-to-text | `tools.media.audio` | `scribe_v2` |
| Streaming speech-to-text | Voice Call `streaming.provider: "elevenlabs"` | `scribe_v2_realtime` |
## Authentication
Set `ELEVENLABS_API_KEY` in the environment. `XI_API_KEY` is also accepted for
compatibility with existing ElevenLabs tooling.
```bash
export ELEVENLABS_API_KEY="..."
```
## Text-to-speech
```json5
{
messages: {
tts: {
providers: {
elevenlabs: {
apiKey: "${ELEVENLABS_API_KEY}",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2",
},
},
},
},
}
```
## Speech-to-text
Use Scribe v2 for inbound audio attachments and short recorded voice segments:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "elevenlabs", model: "scribe_v2" }],
},
},
},
}
```
OpenClaw sends multipart audio to ElevenLabs `/v1/speech-to-text` with
`model_id: "scribe_v2"`. Language hints map to `language_code` when present.
## Voice Call streaming STT
The bundled `elevenlabs` plugin registers Scribe v2 Realtime for Voice Call
streaming transcription.
| Setting | Config path | Default |
| --------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.elevenlabs.apiKey` | Falls back to `ELEVENLABS_API_KEY` / `XI_API_KEY` |
| Model | `...elevenlabs.modelId` | `scribe_v2_realtime` |
| Audio format | `...elevenlabs.audioFormat` | `ulaw_8000` |
| Sample rate | `...elevenlabs.sampleRate` | `8000` |
| Commit strategy | `...elevenlabs.commitStrategy` | `vad` |
| Language | `...elevenlabs.languageCode` | (unset) |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "${ELEVENLABS_API_KEY}",
audioFormat: "ulaw_8000",
commitStrategy: "vad",
languageCode: "en",
},
},
},
},
},
},
},
}
```
<Note>
Voice Call receives Twilio media as 8 kHz G.711 u-law. The ElevenLabs realtime
provider defaults to `ulaw_8000`, so telephony frames can be forwarded without
transcoding.
</Note>

View File

@@ -82,10 +82,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
## Transcription providers
- [Deepgram (audio transcription)](/providers/deepgram)
- [ElevenLabs](/providers/elevenlabs#speech-to-text)
- [Mistral](/providers/mistral#audio-transcription-voxtral)
- [OpenAI](/providers/openai#speech-to-text)
- [xAI](/providers/xai#speech-to-text)
## Community tools

View File

@@ -2,7 +2,6 @@
summary: "Use Mistral models and Voxtral transcription with OpenClaw"
read_when:
- You want to use Mistral models in OpenClaw
- You want Voxtral realtime transcription for Voice Call
- You need Mistral API key onboarding and model refs
title: "Mistral"
---
@@ -66,8 +65,7 @@ OpenClaw currently ships this bundled Mistral catalog:
## Audio transcription (Voxtral)
Use Voxtral for batch audio transcription through the media understanding
pipeline.
Use Voxtral for audio transcription through the media understanding pipeline.
```json5
{
@@ -86,48 +84,6 @@ pipeline.
The media transcription path uses `/v1/audio/transcriptions`. The default audio model for Mistral is `voxtral-mini-latest`.
</Tip>
## Voice Call streaming STT
The bundled `mistral` plugin registers Voxtral Realtime as a Voice Call
streaming STT provider.
| Setting | Config path | Default |
| ------------ | ---------------------------------------------------------------------- | --------------------------------------- |
| API key | `plugins.entries.voice-call.config.streaming.providers.mistral.apiKey` | Falls back to `MISTRAL_API_KEY` |
| Model | `...mistral.model` | `voxtral-mini-transcribe-realtime-2602` |
| Encoding | `...mistral.encoding` | `pcm_mulaw` |
| Sample rate | `...mistral.sampleRate` | `8000` |
| Target delay | `...mistral.targetStreamingDelayMs` | `800` |
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "mistral",
providers: {
mistral: {
apiKey: "${MISTRAL_API_KEY}",
targetStreamingDelayMs: 800,
},
},
},
},
},
},
},
}
```
<Note>
OpenClaw defaults Mistral realtime STT to `pcm_mulaw` at 8 kHz so Voice Call
can forward Twilio media frames directly. Use `encoding: "pcm_s16le"` and a
matching `sampleRate` only if your upstream stream is already raw PCM.
</Note>
## Advanced configuration
<AccordionGroup>

View File

@@ -16,21 +16,6 @@ OpenAI provides developer APIs for GPT models. OpenClaw supports two auth routes
OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw.
## OpenClaw feature coverage
| OpenAI capability | OpenClaw surface | Status |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------ |
| Chat / Responses | `openai/<model>` model provider | Yes |
| Codex subscription models | `openai-codex/<model>` model provider | Yes |
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Yes |
| Text-to-speech | `messages.tts.provider: "openai"` / `tts` | Yes |
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes |
| Realtime voice | Voice Call `realtime.provider: "openai"` | Yes |
| Embeddings | memory embedding provider | Yes |
## Getting started
Choose your preferred auth method and follow the setup steps.
@@ -237,9 +222,7 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov
## GPT-5 prompt contribution
OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs across providers. It applies by model id, so `openai/gpt-5.4`, `openai-codex/gpt-5.4`, `openrouter/openai/gpt-5.4`, `opencode/gpt-5.4`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not.
The bundled native Codex harness provider (`codex/*`) uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `codex/gpt-5.x` sessions keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt.
OpenClaw adds an OpenAI-specific GPT-5 prompt contribution for `openai/*` and `openai-codex/*` GPT-5-family runs. It lives in the bundled OpenAI plugin, applies to model ids such as `gpt-5`, `gpt-5.2`, `gpt-5.4`, and `gpt-5.4-mini`, and does not apply to older GPT-4.x models.
The GPT-5 contribution adds a tagged behavior contract for persona persistence, execution safety, tool discipline, output shape, completion checks, and verification. Channel-specific reply and silent-message behavior stays in the shared OpenClaw system prompt and outbound delivery policy. The GPT-5 guidance is always enabled for matching models. The friendly interaction-style layer is separate and configurable.
@@ -253,11 +236,9 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
<Tab title="Config">
```json5
{
agents: {
defaults: {
promptOverlays: {
gpt5: { personality: "friendly" },
},
plugins: {
entries: {
openai: { config: { personality: "friendly" } },
},
},
}
@@ -265,7 +246,7 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
</Tab>
<Tab title="CLI">
```bash
openclaw config set agents.defaults.promptOverlays.gpt5.personality off
openclaw config set plugins.entries.openai.config.personality off
```
</Tab>
</Tabs>
@@ -274,10 +255,6 @@ The GPT-5 contribution adds a tagged behavior contract for persona persistence,
Values are case-insensitive at runtime, so `"Off"` and `"off"` both disable the friendly style layer.
</Tip>
<Note>
Legacy `plugins.entries.openai.config.personality` is still read as a compatibility fallback when the shared `agents.defaults.promptOverlays.gpt5.personality` setting is not set.
</Note>
## Voice and speech
<AccordionGroup>
@@ -314,56 +291,18 @@ Legacy `plugins.entries.openai.config.personality` is still read as a compatibil
</Accordion>
<Accordion title="Speech-to-text">
The bundled `openai` plugin registers batch speech-to-text through
OpenClaw's media-understanding transcription surface.
- Default model: `gpt-4o-transcribe`
- Endpoint: OpenAI REST `/v1/audio/transcriptions`
- Input path: multipart audio file upload
- Supported by OpenClaw wherever inbound audio transcription uses
`tools.media.audio`, including Discord voice-channel segments and channel
audio attachments
To force OpenAI for inbound audio transcription:
```json5
{
tools: {
media: {
audio: {
models: [
{
type: "provider",
provider: "openai",
model: "gpt-4o-transcribe",
},
],
},
},
},
}
```
Language and prompt hints are forwarded to OpenAI when supplied by the
shared audio media config or per-call transcription request.
</Accordion>
<Accordion title="Realtime transcription">
The bundled `openai` plugin registers realtime transcription for the Voice Call plugin.
| Setting | Config path | Default |
|---------|------------|---------|
| Model | `plugins.entries.voice-call.config.streaming.providers.openai.model` | `gpt-4o-transcribe` |
| Language | `...openai.language` | (unset) |
| Prompt | `...openai.prompt` | (unset) |
| Silence duration | `...openai.silenceDurationMs` | `800` |
| VAD threshold | `...openai.vadThreshold` | `0.5` |
| API key | `...openai.apiKey` | Falls back to `OPENAI_API_KEY` |
<Note>
Uses a WebSocket connection to `wss://api.openai.com/v1/realtime` with G.711 u-law (`g711_ulaw` / `audio/pcmu`) audio. This streaming provider is for Voice Call's realtime transcription path; Discord voice currently records short segments and uses the batch `tools.media.audio` transcription path instead.
Uses a WebSocket connection to `wss://api.openai.com/v1/realtime` with G.711 u-law audio.
</Note>
</Accordion>

View File

@@ -63,35 +63,6 @@ they follow the same API shape.
current image-capable Grok refs in the bundled catalog.
</Tip>
## OpenClaw feature coverage
The bundled plugin maps xAI's current public API surface onto OpenClaw's shared
provider and tool contracts where the behavior fits cleanly.
| xAI capability | OpenClaw surface | Status |
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------------- |
| Chat / Responses | `xai/<model>` model provider | Yes |
| Server-side web search | `web_search` provider `grok` | Yes |
| Server-side X search | `x_search` tool | Yes |
| Server-side code execution | `code_execution` tool | Yes |
| Images | `image_generate` | Yes |
| Videos | `video_generate` | Yes |
| Batch text-to-speech | `messages.tts.provider: "xai"` / `tts` | Yes |
| Streaming TTS | — | Not exposed; OpenClaw's TTS contract returns complete audio buffers |
| Batch speech-to-text | `tools.media.audio` / media understanding | Yes |
| Streaming speech-to-text | Voice Call `streaming.provider: "xai"` | Yes |
| Realtime voice | — | Not exposed yet; different session/WebSocket contract |
| Files / batches | Generic model API compatibility only | Not a first-class OpenClaw tool |
<Note>
OpenClaw uses xAI's REST image/video/TTS/STT APIs for media generation,
speech, and batch transcription, xAI's streaming STT WebSocket for live
voice-call transcription, and the Responses API for model, search, and
code-execution tools. Features that need different OpenClaw contracts, such as
Realtime voice sessions, are documented here as upstream capabilities rather
than hidden plugin behavior.
</Note>
### Fast-mode mappings
`/fast on` or `agents.defaults.models["xai/<model>"].params.fastMode: true`
@@ -132,17 +103,12 @@ Legacy aliases still normalize to the canonical bundled ids:
`video_generate` tool.
- Default video model: `xai/grok-imagine-video`
- Modes: text-to-video, image-to-video, remote video edit, and remote video
extension
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`
- Resolutions: `480P`, `720P`
- Duration: 1-15 seconds for generation/image-to-video, 2-10 seconds for
extension
- Modes: text-to-video, image-to-video, and remote video edit/extend flows
- Supports `aspectRatio` and `resolution`
<Warning>
Local video buffers are not accepted. Use remote `http(s)` URLs for
video edit/extend inputs. Image-to-video accepts local image buffers because
OpenClaw can encode those as data URLs for xAI.
video-reference and edit inputs.
</Warning>
To use xAI as the default video provider:
@@ -166,170 +132,6 @@ Legacy aliases still normalize to the canonical bundled ids:
</Accordion>
<Accordion title="Image generation">
The bundled `xai` plugin registers image generation through the shared
`image_generate` tool.
- Default image model: `xai/grok-imagine-image`
- Additional model: `xai/grok-imagine-image-pro`
- Modes: text-to-image and reference-image edit
- Reference inputs: one `image` or up to five `images`
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2:3`, `3:2`
- Resolutions: `1K`, `2K`
- Count: up to 4 images
OpenClaw asks xAI for `b64_json` image responses so generated media can be
stored and delivered through the normal channel attachment path. Local
reference images are converted to data URLs; remote `http(s)` references are
passed through.
To use xAI as the default image provider:
```json5
{
agents: {
defaults: {
imageGenerationModel: {
primary: "xai/grok-imagine-image",
},
},
},
}
```
<Note>
xAI also documents `quality`, `mask`, `user`, and additional native ratios
such as `1:2`, `2:1`, `9:20`, and `20:9`. OpenClaw forwards only the
shared cross-provider image controls today; unsupported native-only knobs
are intentionally not exposed through `image_generate`.
</Note>
</Accordion>
<Accordion title="Text-to-speech">
The bundled `xai` plugin registers text-to-speech through the shared `tts`
provider surface.
- Voices: `eve`, `ara`, `rex`, `sal`, `leo`, `una`
- Default voice: `eve`
- Formats: `mp3`, `wav`, `pcm`, `mulaw`, `alaw`
- Language: BCP-47 code or `auto`
- Speed: provider-native speed override
- Native Opus voice-note format is not supported
To use xAI as the default TTS provider:
```json5
{
messages: {
tts: {
provider: "xai",
providers: {
xai: {
voiceId: "eve",
},
},
},
},
}
```
<Note>
OpenClaw uses xAI's batch `/v1/tts` endpoint. xAI also offers streaming TTS
over WebSocket, but the OpenClaw speech provider contract currently expects
a complete audio buffer before reply delivery.
</Note>
</Accordion>
<Accordion title="Speech-to-text">
The bundled `xai` plugin registers batch speech-to-text through OpenClaw's
media-understanding transcription surface.
- Default model: `grok-stt`
- Endpoint: xAI REST `/v1/stt`
- Input path: multipart audio file upload
- Supported by OpenClaw wherever inbound audio transcription uses
`tools.media.audio`, including Discord voice-channel segments and
channel audio attachments
To force xAI for inbound audio transcription:
```json5
{
tools: {
media: {
audio: {
models: [
{
type: "provider",
provider: "xai",
model: "grok-stt",
},
],
},
},
},
}
```
Language can be supplied through the shared audio media config or per-call
transcription request. Prompt hints are accepted by the shared OpenClaw
surface, but the xAI REST STT integration only forwards file, model, and
language because those map cleanly to the current public xAI endpoint.
</Accordion>
<Accordion title="Streaming speech-to-text">
The bundled `xai` plugin also registers a realtime transcription provider
for live voice-call audio.
- Endpoint: xAI WebSocket `wss://api.x.ai/v1/stt`
- Default encoding: `mulaw`
- Default sample rate: `8000`
- Default endpointing: `800ms`
- Interim transcripts: enabled by default
Voice Call's Twilio media stream sends G.711 µ-law audio frames, so the
xAI provider can forward those frames directly without transcoding:
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
streaming: {
enabled: true,
provider: "xai",
providers: {
xai: {
apiKey: "${XAI_API_KEY}",
endpointingMs: 800,
language: "en",
},
},
},
},
},
},
},
}
```
Provider-owned config lives under
`plugins.entries.voice-call.config.streaming.providers.xai`. Supported
keys are `apiKey`, `baseUrl`, `sampleRate`, `encoding` (`pcm`, `mulaw`, or
`alaw`), `interimResults`, `endpointingMs`, and `language`.
<Note>
This streaming provider is for Voice Call's realtime transcription path.
Discord voice currently records short segments and uses the batch
`tools.media.audio` transcription path instead.
</Note>
</Accordion>
<Accordion title="x_search configuration">
The bundled xAI plugin exposes `x_search` as an OpenClaw tool for searching
X (formerly Twitter) content via Grok.
@@ -407,12 +209,6 @@ Legacy aliases still normalize to the canonical bundled ids:
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.
- xAI image `quality`, image `mask`, and extra native-only aspect ratios are
not exposed until the shared `image_generate` tool has corresponding
cross-provider controls.
</Accordion>
<Accordion title="Advanced notes">
@@ -433,24 +229,6 @@ Legacy aliases still normalize to the canonical bundled ids:
</Accordion>
</AccordionGroup>
## Live testing
The xAI media paths are covered by unit tests and opt-in live suites. The live
commands load secrets from your login shell, including `~/.profile`, before
probing `XAI_API_KEY`.
```bash
pnpm test extensions/xai
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/xai.live.test.ts
OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS=xai pnpm test:live -- test/image-generation.runtime.live.test.ts
```
The provider-specific live file synthesizes normal TTS, telephony-friendly PCM
TTS, transcribes audio through xAI batch STT, streams the same PCM through xAI
realtime STT, generates text-to-image output, and edits a reference image. The
shared image live file verifies the same xAI provider through OpenClaw's
runtime selection, fallback, normalization, and media attachment path.
## Related
<CardGroup cols={2}>

View File

@@ -33,10 +33,9 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic API key**: preferred Anthropic assistant choice in onboarding/configure.
- **Anthropic setup-token**: still available in onboarding/configure, though OpenClaw now prefers Claude CLI reuse when available.
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, onboarding can reuse it. Reused Codex CLI credentials stay managed by Codex CLI; on expiry OpenClaw re-reads that source first and, when the provider can refresh it, writes the refreshed credential back to Codex storage instead of taking ownership itself.
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
- **OpenAI Code (Codex) subscription (device pairing)**: browser pairing flow with a short-lived device code.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- Sets `agents.defaults.model` to `openai/gpt-5.4` when model is unset, `openai/*`, or `openai-codex/*`.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.

View File

@@ -129,17 +129,18 @@ What you set:
<Accordion title="Anthropic API key">
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
</Accordion>
<Accordion title="OpenAI Code subscription (Codex CLI reuse)">
If `~/.codex/auth.json` exists, the wizard can reuse it.
Reused Codex CLI credentials stay managed by Codex CLI; on expiry OpenClaw
re-reads that source first and, when the provider can refresh it, writes
the refreshed credential back to Codex storage instead of taking ownership
itself.
</Accordion>
<Accordion title="OpenAI Code subscription (OAuth)">
Browser flow; paste `code#state`.
Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
</Accordion>
<Accordion title="OpenAI Code subscription (device pairing)">
Browser pairing flow with a short-lived device code.
Sets `agents.defaults.model` to `openai-codex/gpt-5.4` when model is unset or `openai/*`.
</Accordion>
<Accordion title="OpenAI API key">
Uses `OPENAI_API_KEY` if present or prompts for a key, then stores the credential in auth profiles.

View File

@@ -813,23 +813,6 @@ Security and trust notes:
Custom `mcpServers` still work as before. The built-in plugin-tools bridge is an
additional opt-in convenience, not a replacement for generic MCP server config.
### OpenClaw tools MCP bridge
By default, ACPX sessions also do **not** expose built-in OpenClaw tools through
MCP. Enable the separate core-tools bridge when an ACP agent needs selected
built-in tools such as `cron`:
```bash
openclaw config set plugins.entries.acpx.config.openClawToolsMcpBridge true
```
What this does:
- Injects a built-in MCP server named `openclaw-tools` into ACPX session
bootstrap.
- Exposes selected built-in OpenClaw tools. The initial server exposes `cron`.
- Keeps core-tool exposure explicit and default-off.
### Runtime timeout configuration
The bundled `acpx` plugin defaults embedded runtime turns to a 120-second

View File

@@ -637,10 +637,9 @@ What still needs Playwright:
Element screenshots also reject `--full-page`; the route returns `fullPage is
not supported for element screenshots`.
If you see `Playwright is not available in this gateway build`, repair the
bundled browser plugin runtime dependencies so `playwright-core` is installed,
then restart the gateway. For packaged installs, run `openclaw doctor --fix`.
For Docker, also install the Chromium browser binaries as shown below.
If you see `Playwright is not available in this gateway build`, install the full
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
OpenClaw with browser support.
#### Docker Playwright install

View File

@@ -1,5 +1,5 @@
---
summary: "Generate and edit images using configured providers (OpenAI, Google Gemini, fal, MiniMax, ComfyUI, Vydra, xAI)"
summary: "Generate and edit images using configured providers (OpenAI, Google Gemini, fal, MiniMax, ComfyUI, Vydra)"
read_when:
- Generating images via the agent
- Configuring image generation providers and models
@@ -46,7 +46,6 @@ The agent calls `image_generate` automatically. No tool allow-listing needed —
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
| Vydra | `grok-imagine` | No | `VYDRA_API_KEY` |
| xAI | `grok-imagine-image` | Yes (up to 5 images) | `XAI_API_KEY` |
Use `action: "list"` to inspect available providers and models at runtime:
@@ -116,13 +115,13 @@ Notes:
### Image editing
OpenAI, Google, fal, MiniMax, ComfyUI, and xAI support editing reference images. Pass a reference image path or URL:
OpenAI, Google, fal, MiniMax, and ComfyUI support editing reference images. Pass a reference image path or URL:
```
"Generate a watercolor version of this photo" + image: "/path/to/photo.jpg"
```
OpenAI, Google, and xAI support up to 5 reference images via the `images` parameter. fal, MiniMax, and ComfyUI support 1.
OpenAI and Google support up to 5 reference images via the `images` parameter. fal, MiniMax, and ComfyUI support 1.
### OpenAI `gpt-image-2`
@@ -167,29 +166,13 @@ MiniMax image generation is available through both bundled MiniMax auth paths:
## Provider capabilities
| Capability | OpenAI | Google | fal | MiniMax | ComfyUI | Vydra | xAI |
| --------------------- | -------------------- | -------------------- | ------------------- | -------------------------- | ---------------------------------- | ------- | -------------------- |
| Generate | Yes (up to 4) | Yes (up to 4) | Yes (up to 4) | Yes (up to 9) | Yes (workflow-defined outputs) | Yes (1) | Yes (up to 4) |
| Edit/reference | Yes (up to 5 images) | Yes (up to 5 images) | Yes (1 image) | Yes (1 image, subject ref) | Yes (1 image, workflow-configured) | No | Yes (up to 5 images) |
| Size control | Yes (up to 4K) | Yes | Yes | No | No | No | No |
| Aspect ratio | No | Yes | Yes (generate only) | Yes | No | No | Yes |
| Resolution (1K/2K/4K) | No | Yes | Yes | No | No | No | Yes (1K/2K) |
### xAI `grok-imagine-image`
The bundled xAI provider uses `/v1/images/generations` for prompt-only requests
and `/v1/images/edits` when `image` or `images` is present.
- Models: `xai/grok-imagine-image`, `xai/grok-imagine-image-pro`
- Count: up to 4
- References: one `image` or up to five `images`
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2:3`, `3:2`
- Resolutions: `1K`, `2K`
- Outputs: returned as OpenClaw-managed image attachments
OpenClaw intentionally does not expose xAI-native `quality`, `mask`, `user`, or
extra native-only aspect ratios until those controls exist in the shared
cross-provider `image_generate` contract.
| Capability | OpenAI | Google | fal | MiniMax | ComfyUI | Vydra |
| --------------------- | -------------------- | -------------------- | ------------------- | -------------------------- | ---------------------------------- | ------- |
| Generate | Yes (up to 4) | Yes (up to 4) | Yes (up to 4) | Yes (up to 9) | Yes (workflow-defined outputs) | Yes (1) |
| Edit/reference | Yes (up to 5 images) | Yes (up to 5 images) | Yes (1 image) | Yes (1 image, subject ref) | Yes (1 image, workflow-configured) | No |
| Size control | Yes (up to 4K) | Yes | Yes | No | No | No |
| Aspect ratio | No | Yes | Yes (generate only) | Yes | No | No |
| Resolution (1K/2K/4K) | No | Yes | Yes | No | No | No |
## Related
@@ -200,6 +183,5 @@ cross-provider `image_generate` contract.
- [MiniMax](/providers/minimax) — MiniMax image provider setup
- [OpenAI](/providers/openai) — OpenAI Images provider setup
- [Vydra](/providers/vydra) — Vydra image, video, and speech setup
- [xAI](/providers/xai) — Grok image, video, search, code execution, and TTS setup
- [Configuration Reference](/gateway/configuration-reference#agent-defaults) — `imageGenerationModel` config
- [Models](/concepts/models) — model configuration and failover

View File

@@ -139,11 +139,6 @@ Per-agent override: `agents.list[].tools.profile`.
| `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` |
| `minimal` | `session_status` only |
The `coding` and `messaging` profiles also allow configured bundle MCP tools
under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you
want a profile to keep its normal built-ins but hide all configured MCP tools.
The `minimal` profile does not include bundle MCP tools.
### Tool groups
Use `group:*` shorthands in allow/deny lists:

View File

@@ -15,10 +15,10 @@ OpenClaw generates images, videos, and music, understands inbound media (images,
| Capability | Tool | Providers | What it does |
| -------------------- | ---------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra, xAI | Creates or edits images from text prompts or references |
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra | Creates or edits images from text prompts or references |
| Video generation | `video_generate` | Alibaba, BytePlus, ComfyUI, fal, Google, MiniMax, OpenAI, Qwen, Runway, Together, Vydra, xAI | Creates videos from text, images, or existing videos |
| Music generation | `music_generate` | ComfyUI, Google, MiniMax | Creates music or audio tracks from text prompts |
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI, xAI | Converts outbound replies to spoken audio |
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI | Converts outbound replies to spoken audio |
| Media understanding | (automatic) | Any vision/audio-capable model provider, plus CLI fallbacks | Summarizes inbound images, audio, and video |
## Provider capability matrix
@@ -31,18 +31,17 @@ This table shows which providers support which media capabilities across the pla
| BytePlus | | Yes | | | | |
| ComfyUI | Yes | Yes | Yes | | | |
| Deepgram | | | | | Yes | |
| ElevenLabs | | | | Yes | Yes | |
| ElevenLabs | | | | Yes | | |
| fal | Yes | Yes | | | | |
| Google | Yes | Yes | Yes | | | Yes |
| Microsoft | | | | Yes | | |
| MiniMax | Yes | Yes | Yes | Yes | | |
| Mistral | | | | | Yes | |
| OpenAI | Yes | Yes | | Yes | Yes | Yes |
| Qwen | | Yes | | | | |
| Runway | | Yes | | | | |
| Together | | Yes | | | | |
| Vydra | Yes | Yes | | | | |
| xAI | Yes | Yes | | Yes | Yes | Yes |
| xAI | | Yes | | | | |
<Note>
Media understanding uses any vision-capable or audio-capable model registered in your provider config. The table above highlights providers with dedicated media-understanding support; most LLM providers with multimodal models (Anthropic, Google, OpenAI, etc.) can also understand inbound media when configured as the active reply model.
@@ -52,19 +51,6 @@ Media understanding uses any vision-capable or audio-capable model registered in
Video and music generation run as background tasks because provider processing typically takes 30 seconds to several minutes. When the agent calls `video_generate` or `music_generate`, OpenClaw submits the request to the provider, returns a task ID immediately, and tracks the job in the task ledger. The agent continues responding to other messages while the job runs. When the provider finishes, OpenClaw wakes the agent so it can post the finished media back into the original channel. Image generation and TTS are synchronous and complete inline with the reply.
Deepgram, ElevenLabs, Mistral, OpenAI, and xAI can all transcribe inbound
audio through the batch `tools.media.audio` path when configured. Deepgram,
ElevenLabs, Mistral, OpenAI, and xAI also register Voice Call streaming STT
providers, so live phone audio can be forwarded to the selected vendor
without waiting for a completed recording.
OpenAI maps to OpenClaw's image, video, batch TTS, batch STT, Voice Call
streaming STT, realtime voice, and memory embedding surfaces. xAI currently
maps to OpenClaw's image, video, search, code-execution, batch TTS, batch STT,
and Voice Call streaming STT surfaces. xAI Realtime voice is an upstream
capability, but it is not registered in OpenClaw until the shared realtime
voice contract can represent it.
## Quick links
- [Image Generation](/tools/image-generation) -- generating and editing images

View File

@@ -234,8 +234,8 @@ openclaw plugins install <plugin> --marketplace <source>
openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo>
openclaw plugins install <spec> --pin # record exact resolved npm spec
openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id-or-npm-spec> # update one plugin
openclaw plugins update <id-or-npm-spec> --dangerously-force-unsafe-install
openclaw plugins update <id> # update one plugin
openclaw plugins update <id> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins uninstall <id> # remove config/install records
openclaw plugins uninstall <id> --keep-files
@@ -250,18 +250,9 @@ Bundled plugins ship with OpenClaw. Many are enabled by default (for example
bundled model providers, bundled speech providers, and the bundled browser
plugin). Other bundled plugins still need `openclaw plugins enable <id>`.
`--force` overwrites an existing installed plugin or hook pack in place. Use
`openclaw plugins update <id-or-npm-spec>` for routine upgrades of tracked npm
plugins. It is not supported with `--link`, which reuses the source path instead
of copying over a managed install target.
`openclaw plugins update <id-or-npm-spec>` applies to tracked installs. Passing
an npm package spec with a dist-tag or exact version resolves the package name
back to the tracked plugin record and records the new spec for future updates.
Passing the package name without a version moves an exact pinned install back to
the registry's default release line. If the installed npm plugin already matches
the resolved version and recorded artifact identity, OpenClaw skips the update
without downloading, reinstalling, or rewriting config.
`--force` overwrites an existing installed plugin or hook pack in place.
It is not supported with `--link`, which reuses the source path instead of
copying over a managed install target.
`--pin` is npm-only. It is not supported with `--marketplace`, because
marketplace installs persist marketplace source metadata instead of an npm spec.

View File

@@ -9,7 +9,7 @@ title: "Text-to-Speech"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, Microsoft, MiniMax, OpenAI, or xAI.
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, Microsoft, MiniMax, or OpenAI.
It works anywhere OpenClaw can send audio.
## Supported services
@@ -19,7 +19,6 @@ It works anywhere OpenClaw can send audio.
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **MiniMax** (primary or fallback provider; uses the T2A v2 API)
- **OpenAI** (primary or fallback provider; also used for summaries)
- **xAI** (primary or fallback provider; uses the xAI TTS API)
### Microsoft speech notes
@@ -36,13 +35,12 @@ or ElevenLabs.
## Optional keys
If you want OpenAI, ElevenLabs, Google Gemini, MiniMax, or xAI:
If you want OpenAI, ElevenLabs, Google Gemini, or MiniMax:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `GEMINI_API_KEY` (or `GOOGLE_API_KEY`)
- `MINIMAX_API_KEY`
- `OPENAI_API_KEY`
- `XAI_API_KEY`
Microsoft speech does **not** require an API key.
@@ -59,7 +57,6 @@ so that provider must also be authenticated if you enable summaries.
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
- [xAI Text to Speech](https://docs.x.ai/developers/rest-api-reference/inference/voice#text-to-speech-rest)
## Is it enabled by default?
@@ -201,33 +198,6 @@ by the bundled Google image-generation provider. Resolution order is
`messages.tts.providers.google.apiKey` -> `models.providers.google.apiKey` ->
`GEMINI_API_KEY` -> `GOOGLE_API_KEY`.
### xAI primary
```json5
{
messages: {
tts: {
auto: "always",
provider: "xai",
providers: {
xai: {
apiKey: "xai_api_key",
voiceId: "eve",
language: "en",
responseFormat: "mp3",
speed: 1.0,
},
},
},
},
}
```
xAI TTS uses the same `XAI_API_KEY` path as the bundled Grok model provider.
Resolution order is `messages.tts.providers.xai.apiKey` -> `XAI_API_KEY`.
Current live voices are `ara`, `eve`, `leo`, `rex`, `sal`, and `una`; `eve` is
the default. `language` accepts a BCP-47 tag or `auto`.
### Disable Microsoft speech
```json5
@@ -330,12 +300,6 @@ Then run:
- `providers.google.voiceName`: Gemini prebuilt voice name (default `Kore`; `voice` is also accepted).
- `providers.google.baseUrl`: override the Gemini API base URL. Only `https://generativelanguage.googleapis.com` is accepted.
- If `messages.tts.providers.google.apiKey` is omitted, TTS can reuse `models.providers.google.apiKey` before env fallback.
- `providers.xai.apiKey`: xAI TTS API key (env: `XAI_API_KEY`).
- `providers.xai.baseUrl`: override the xAI TTS base URL (default `https://api.x.ai/v1`, env: `XAI_BASE_URL`).
- `providers.xai.voiceId`: xAI voice id (default `eve`; current live voices: `ara`, `eve`, `leo`, `rex`, `sal`, `una`).
- `providers.xai.language`: BCP-47 language code or `auto` (default `en`).
- `providers.xai.responseFormat`: `mp3`, `wav`, `pcm`, `mulaw`, or `alaw` (default `mp3`).
- `providers.xai.speed`: provider-native speed override.
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
@@ -371,7 +335,7 @@ Here you go.
Available directive keys (when enabled):
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `google`, `minimax`, or `microsoft`; requires `allowProvider: true`)
- `voice` (OpenAI voice), `voiceName` / `voice_name` / `google_voice` (Google voice), or `voiceId` (ElevenLabs / MiniMax / xAI)
- `voice` (OpenAI voice), `voiceName` / `voice_name` / `google_voice` (Google voice), or `voiceId` (ElevenLabs / MiniMax)
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model) or `google_model` (Google TTS model)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
- `vol` / `volume` (MiniMax volume, 0-10)
@@ -433,7 +397,6 @@ These override `messages.tts.*` for that host.
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **MiniMax**: MP3 (`speech-2.8-hd` model, 32kHz sample rate). Voice-note format not natively supported; use OpenAI or ElevenLabs for guaranteed Opus voice messages.
- **Google Gemini**: Gemini API TTS returns raw 24kHz PCM. OpenClaw wraps it as WAV for audio attachments and returns PCM directly for Talk/telephony. Native Opus voice-note format is not supported by this path.
- **xAI**: MP3 by default; `responseFormat` may be `mp3`, `wav`, `pcm`, `mulaw`, or `alaw`. OpenClaw uses xAI's batch REST TTS endpoint and returns a complete audio attachment; xAI's streaming TTS WebSocket is not used by this provider path. Native Opus voice-note format is not supported by this path.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).

View File

@@ -116,10 +116,6 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
## Auto-detection
## Native OpenAI web search
Direct OpenAI Responses models use OpenAI's hosted `web_search` tool automatically when OpenClaw web search is enabled and no managed provider is pinned. This is provider-owned behavior in the bundled OpenAI plugin and only applies to native OpenAI API traffic, not OpenAI-compatible proxy base URLs or Azure routes. Set `tools.web.search.provider` to another provider such as `brave` to keep the managed `web_search` tool for OpenAI models, or set `tools.web.search.enabled: false` to disable both managed search and native OpenAI search.
## Native Codex web search
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.

View File

@@ -1,7 +1,4 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import setupPlugin from "./setup-api.js";
const { createAcpxRuntimeServiceMock, tryDispatchAcpReplyHookMock } = vi.hoisted(() => ({
createAcpxRuntimeServiceMock: vi.fn(),
@@ -18,24 +15,6 @@ vi.mock("./runtime-api.js", () => ({
import plugin from "./index.js";
type AcpxAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
function registerAcpxAutoEnableProbe(): AcpxAutoEnableProbe {
const probes: AcpxAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected ACPX setup plugin to register an auto-enable probe");
}
return probe;
}
describe("acpx plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -59,61 +38,4 @@ describe("acpx plugin", () => {
expect(api.registerService).toHaveBeenCalledWith(service);
expect(api.on).toHaveBeenCalledWith("reply_dispatch", tryDispatchAcpReplyHookMock);
});
it("preserves the ACP reply_dispatch runtime path through the registered hook", async () => {
const service = { id: "acpx-service", start: vi.fn() };
createAcpxRuntimeServiceMock.mockReturnValue(service);
tryDispatchAcpReplyHookMock.mockResolvedValue({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 0, final: 1 },
});
const on = vi.fn();
const api = createTestPluginApi({
pluginConfig: { stateDir: "/tmp/acpx" },
registerService: vi.fn(),
on,
});
plugin.register(api);
const hook = on.mock.calls.find(([hookName]) => hookName === "reply_dispatch")?.[1];
if (!hook) {
throw new Error("expected reply_dispatch hook to be registered");
}
const event = {
ctx: { raw: "reply ctx" },
runId: "run-1",
sessionKey: "agent:test:session",
inboundAudio: false,
shouldRouteToOriginating: false,
shouldSendToolSummaries: true,
sendPolicy: "allow",
};
const ctx = {
cfg: {},
dispatcher: { dispatch: vi.fn(), getQueuedCounts: vi.fn(), getFailedCounts: vi.fn() },
recordProcessed: vi.fn(),
markIdle: vi.fn(),
};
await expect(hook(event, ctx)).resolves.toEqual({
handled: true,
queuedFinal: true,
counts: { tool: 1, block: 0, final: 1 },
});
expect(tryDispatchAcpReplyHookMock).toHaveBeenCalledWith(event, ctx);
});
it("declares setup auto-enable reasons for ACPX-owned ACP config", () => {
const probe = registerAcpxAutoEnableProbe();
expect(probe({ config: { acp: { enabled: true } }, env: {} })).toBe("ACP runtime configured");
expect(probe({ config: { acp: { backend: "acpx" } }, env: {} })).toBe("ACP runtime configured");
expect(probe({ config: { acp: { enabled: true, backend: "custom-runtime" } }, env: {} })).toBe(
null,
);
});
});

View File

@@ -31,9 +31,6 @@
"pluginToolsMcpBridge": {
"type": "boolean"
},
"openClawToolsMcpBridge": {
"type": "boolean"
},
"strictWindowsCmdWrapper": {
"type": "boolean"
},
@@ -112,11 +109,6 @@
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
"advanced": true
},
"openClawToolsMcpBridge": {
"label": "OpenClaw Tools MCP Bridge",
"help": "Default off. When enabled, inject the built-in OpenClaw core-tools MCP server into embedded ACP sessions so ACP agents can call selected built-in tools such as cron.",
"advanced": true
},
"strictWindowsCmdWrapper": {
"label": "Strict Windows cmd Wrapper",
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",

View File

@@ -1,109 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import { resolveAcpxPluginConfig } from "./config.js";
const tempDirs: string[] = [];
const previousEnv = {
CODEX_HOME: process.env.CODEX_HOME,
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
};
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-codex-auth-"));
tempDirs.push(dir);
return dir;
}
function restoreEnv(name: keyof typeof previousEnv): void {
const value = previousEnv[name];
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
function unquoteCommandPath(command: string): string {
return command.replace(/^'|'$/g, "").replace(/'\\''/g, "'");
}
afterEach(async () => {
restoreEnv("CODEX_HOME");
restoreEnv("OPENCLAW_AGENT_DIR");
restoreEnv("PI_CODING_AGENT_DIR");
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
describe("prepareAcpxCodexAuthConfig", () => {
it("wraps built-in Codex ACP with an isolated CODEX_HOME copy", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }, null, 2)}\n`,
);
await fs.writeFile(path.join(sourceCodexHome, "config.toml"), 'model = "gpt-5.4"\n');
process.env.CODEX_HOME = sourceCodexHome;
process.env.OPENCLAW_AGENT_DIR = agentDir;
delete process.env.PI_CODING_AGENT_DIR;
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
});
const wrapperPath = unquoteCommandPath(resolved.agents.codex ?? "");
expect(wrapperPath).toBe(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"));
await expect(fs.access(wrapperPath)).resolves.toBeUndefined();
const isolatedAuthPath = path.join(agentDir, "acp-auth", "codex-source", "auth.json");
const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as {
auth_mode?: string;
OPENAI_API_KEY?: string;
};
expect(copiedAuth).toEqual({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" });
expect((await fs.stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
await expect(
fs.readFile(path.join(agentDir, "acp-auth", "codex-source", "config.toml"), "utf8"),
).resolves.toBe('model = "gpt-5.4"\n');
const wrapper = await fs.readFile(wrapperPath, "utf8");
expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`);
expect(wrapper).toContain("delete env[key]");
expect(wrapper).not.toContain("test-api-key");
});
it("does not override an explicitly configured Codex agent command", async () => {
const root = await makeTempDir();
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {
agents: {
codex: {
command: "custom-codex-acp",
},
},
},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir: path.join(root, "state"),
});
expect(resolved.agents.codex).toBe("custom-codex-acp");
});
});

View File

@@ -1,157 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
import type { PluginLogger } from "../runtime-api.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
const CODEX_AGENT_ID = "codex";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"];
type PreparedAcpxCodexAuth = {
codexHome: string;
clearEnv: string[];
};
function resolveSourceCodexHome(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.CODEX_HOME?.trim();
if (configured) {
if (configured === "~") {
return os.homedir();
}
if (configured.startsWith("~/")) {
return path.join(os.homedir(), configured.slice(2));
}
return path.resolve(configured);
}
return path.join(os.homedir(), ".codex");
}
async function readOptionalFile(filePath: string): Promise<string | undefined> {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return undefined;
}
throw error;
}
}
async function prepareCopiedCodexHome(params: {
agentDir: string;
sourceCodexHome: string;
}): Promise<PreparedAcpxCodexAuth | null> {
const authJson = await readOptionalFile(path.join(params.sourceCodexHome, "auth.json"));
if (!authJson) {
return null;
}
const codexHome = path.join(params.agentDir, "acp-auth", "codex-source");
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: authJson,
});
const configToml = await readOptionalFile(path.join(params.sourceCodexHome, "config.toml"));
if (configToml) {
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "config.toml"),
content: configToml,
});
}
return {
codexHome,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}
function shellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function writeCodexAcpWrapper(params: {
wrapperPath: string;
codexHome: string;
clearEnv: string[];
}): Promise<string> {
await fs.mkdir(path.dirname(params.wrapperPath), { recursive: true, mode: 0o700 });
const content = `#!/usr/bin/env node
import { spawn } from "node:child_process";
const env = { ...process.env, CODEX_HOME: ${JSON.stringify(params.codexHome)} };
for (const key of ${JSON.stringify(params.clearEnv)}) {
delete env[key];
}
const child = spawn("npx", ["@zed-industries/codex-acp@^0.11.1"], {
stdio: "inherit",
env,
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
`;
await fs.writeFile(params.wrapperPath, content, { mode: 0o700 });
await fs.chmod(params.wrapperPath, 0o700);
return shellArg(params.wrapperPath);
}
export async function prepareAcpxCodexAuthConfig(params: {
pluginConfig: ResolvedAcpxPluginConfig;
stateDir: string;
logger?: PluginLogger;
}): Promise<ResolvedAcpxPluginConfig> {
if (params.pluginConfig.agents[CODEX_AGENT_ID]) {
return params.pluginConfig;
}
const agentDir = resolveOpenClawAgentDir();
const sourceCodexHome = resolveSourceCodexHome();
const bridge =
(await prepareCodexAuthBridge({
agentDir,
bridgeDir: "acp-auth",
profileId: DEFAULT_CODEX_AUTH_PROFILE_ID,
sourceCodexHome,
})) ??
(await prepareCopiedCodexHome({
agentDir,
sourceCodexHome,
}));
if (!bridge) {
params.logger?.debug?.("codex ACP auth bridge skipped: no Codex auth source found");
return params.pluginConfig;
}
const wrapperCommand = await writeCodexAcpWrapper({
wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"),
codexHome: bridge.codexHome,
clearEnv: bridge.clearEnv,
});
return {
...params.pluginConfig,
agents: {
...params.pluginConfig.agents,
[CODEX_AGENT_ID]: wrapperCommand,
},
};
}

View File

@@ -30,7 +30,6 @@ export type AcpxPluginConfig = {
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge?: boolean;
openClawToolsMcpBridge?: boolean;
strictWindowsCmdWrapper?: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
@@ -45,7 +44,6 @@ export type ResolvedAcpxPluginConfig = {
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge: boolean;
openClawToolsMcpBridge: boolean;
strictWindowsCmdWrapper: boolean;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
@@ -93,9 +91,6 @@ export const AcpxPluginConfigSchema = z.strictObject({
})
.optional(),
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
openClawToolsMcpBridge: z
.boolean({ error: "openClawToolsMcpBridge must be a boolean" })
.optional(),
strictWindowsCmdWrapper: z
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
.optional(),

View File

@@ -73,21 +73,6 @@ describe("embedded acpx plugin config", () => {
expect(server.args?.length).toBeGreaterThan(0);
});
it("injects the built-in OpenClaw tools MCP server only when explicitly enabled", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
openClawToolsMcpBridge: true,
},
workspaceDir: "/tmp/openclaw-acpx",
});
const server = resolved.mcpServers["openclaw-tools"];
expect(server).toBeDefined();
expect(server.command).toBe(process.execPath);
expect(Array.isArray(server.args)).toBe(true);
expect(server.args?.length).toBeGreaterThan(0);
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const pluginRoot = resolveAcpxPluginRoot();
const manifest = JSON.parse(
@@ -106,7 +91,6 @@ describe("embedded acpx plugin config", () => {
}),
agents: expect.any(Object),
mcpServers: expect.any(Object),
openClawToolsMcpBridge: expect.any(Object),
}),
});
});

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { formatPluginConfigIssue } from "openclaw/plugin-sdk/extension-shared";
@@ -26,8 +25,6 @@ export {
} from "./config-schema.js";
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
export const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
const requireFromHere = createRequire(import.meta.url);
function isAcpxPluginRoot(dir: string): boolean {
return (
@@ -143,14 +140,6 @@ function resolveOpenClawRoot(currentRoot: string): string {
return path.resolve(currentRoot, "..");
}
function resolveTsxImportSpecifier(): string {
try {
return requireFromHere.resolve("tsx");
} catch {
return "tsx";
}
}
export function resolvePluginToolsMcpServerConfig(
moduleUrl: string = import.meta.url,
): McpServerConfig {
@@ -166,56 +155,25 @@ export function resolvePluginToolsMcpServerConfig(
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
return {
command: process.execPath,
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
};
}
export function resolveOpenClawToolsMcpServerConfig(
moduleUrl: string = import.meta.url,
): McpServerConfig {
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
const openClawRoot = resolveOpenClawRoot(pluginRoot);
const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js");
if (fs.existsSync(distEntry)) {
return {
command: process.execPath,
args: [distEntry],
};
}
const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts");
return {
command: process.execPath,
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
args: ["--import", "tsx", sourceEntry],
};
}
function resolveConfiguredMcpServers(params: {
mcpServers?: Record<string, McpServerConfig>;
pluginToolsMcpBridge: boolean;
openClawToolsMcpBridge: boolean;
moduleUrl?: string;
}): Record<string, McpServerConfig> {
const resolved = { ...params.mcpServers };
if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
if (!params.pluginToolsMcpBridge) {
return resolved;
}
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
throw new Error(
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
);
}
if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) {
throw new Error(
`mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`,
);
}
if (params.pluginToolsMcpBridge) {
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(
params.moduleUrl,
);
}
if (params.openClawToolsMcpBridge) {
resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig(
params.moduleUrl,
);
}
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
return resolved;
}
@@ -246,11 +204,9 @@ export function resolveAcpxPluginConfig(params: {
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
const mcpServers = resolveConfiguredMcpServers({
mcpServers: normalized.mcpServers,
pluginToolsMcpBridge,
openClawToolsMcpBridge,
moduleUrl: params.moduleUrl,
});
const agents = Object.fromEntries(
@@ -268,7 +224,6 @@ export function resolveAcpxPluginConfig(params: {
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
pluginToolsMcpBridge,
openClawToolsMcpBridge,
strictWindowsCmdWrapper:
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,

View File

@@ -6,11 +6,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
}));
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
prepareAcpxCodexAuthConfigMock: vi.fn(
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
@@ -29,10 +24,6 @@ vi.mock("./runtime.js", () => ({
createFileSessionStore: vi.fn(() => ({})),
}));
vi.mock("./codex-auth-bridge.js", () => ({
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
@@ -46,7 +37,6 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {

View File

@@ -7,7 +7,6 @@ import type {
PluginLogger,
} from "../runtime-api.js";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import {
resolveAcpxPluginConfig,
toAcpMcpServers,
@@ -98,15 +97,10 @@ export function createAcpxRuntimeService(
return;
}
const basePluginConfig = resolveAcpxPluginConfig({
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const pluginConfig = await prepareAcpxCodexAuthConfig({
pluginConfig: basePluginConfig,
stateDir: ctx.stateDir,
logger: ctx.logger,
});
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
warnOnIgnoredLegacyCompatibilityConfig({
pluginConfig,

View File

@@ -42,36 +42,10 @@ describe("active-memory plugin", () => {
const runEmbeddedPiAgent = vi.fn();
let stateDir = "";
let configFile: Record<string, unknown> = {};
let pluginConfig: Record<string, unknown> = {
agents: ["main"],
logging: true,
};
const syncRuntimePluginConfig = (nextPluginConfig: Record<string, unknown>) => {
pluginConfig = nextPluginConfig;
const plugins = configFile.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const existingEntry = entries?.["active-memory"] as Record<string, unknown> | undefined;
configFile = {
...configFile,
plugins: {
...plugins,
entries: {
...entries,
"active-memory": {
...existingEntry,
enabled: true,
config: nextPluginConfig,
},
},
},
};
};
const api: any = {
get pluginConfig() {
return pluginConfig;
},
set pluginConfig(nextPluginConfig: Record<string, unknown>) {
syncRuntimePluginConfig(nextPluginConfig);
pluginConfig: {
agents: ["main"],
logging: true,
},
config: {},
id: "active-memory",
@@ -119,10 +93,10 @@ describe("active-memory plugin", () => {
},
},
};
syncRuntimePluginConfig({
api.pluginConfig = {
agents: ["main"],
logging: true,
});
};
api.config = {
agents: {
defaults: {
@@ -336,56 +310,6 @@ describe("active-memory plugin", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
});
it("uses live runtime config for before_prompt_build enablement", async () => {
configFile = {
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
enabled: false,
agents: ["main"],
},
},
},
},
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order after a live config disable?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:live-config-disable",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("fails closed when the live active-memory plugin entry is removed", async () => {
configFile = {
plugins: {
entries: {},
},
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order after active memory is removed?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:live-config-removed",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },

View File

@@ -9,8 +9,6 @@ import {
resolveAgentWorkspaceDir,
} from "openclaw/plugin-sdk/agent-runtime";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
resolveSessionStoreEntry,
updateSessionStore,
type OpenClawConfig,
@@ -575,10 +573,14 @@ function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean {
if (entry?.enabled === false) {
return false;
}
const pluginConfig = resolvePluginConfigObject(cfg, "active-memory");
const pluginConfig = asRecord(entry?.config);
return pluginConfig?.enabled !== false;
}
function resolveActiveMemoryPluginConfigFromConfig(cfg: OpenClawConfig): unknown {
return asRecord(cfg.plugins?.entries?.["active-memory"])?.config;
}
function updateActiveMemoryGlobalEnabledInConfig(
cfg: OpenClawConfig,
enabled: boolean,
@@ -1884,15 +1886,11 @@ export default definePluginEntry({
};
warnDeprecatedModelFallbackPolicy(api.pluginConfig);
const refreshLiveConfigFromRuntime = () => {
const livePluginConfig = resolveLivePluginConfigObject(
api.runtime.config?.loadConfig,
"active-memory",
api.pluginConfig as Record<string, unknown>,
);
config = normalizePluginConfig(livePluginConfig ?? { enabled: false });
if (livePluginConfig) {
warnDeprecatedModelFallbackPolicy(livePluginConfig);
}
const livePluginConfig =
resolveActiveMemoryPluginConfigFromConfig(api.runtime.config.loadConfig()) ??
api.pluginConfig;
config = normalizePluginConfig(livePluginConfig);
warnDeprecatedModelFallbackPolicy(livePluginConfig);
};
api.registerCommand({
name: "active-memory",
@@ -1963,7 +1961,6 @@ export default definePluginEntry({
api.on("before_prompt_build", async (event, ctx) => {
try {
refreshLiveConfigFromRuntime();
const resolvedAgentId = resolveStatusUpdateAgentId(ctx);
const resolvedSessionKey =
ctx.sessionKey?.trim() ||

View File

@@ -1,7 +0,0 @@
{
"specs": [
"@anthropic-ai/sdk@0.81.0",
"@aws/bedrock-token-generator@^1.1.0",
"@mariozechner/pi-ai@0.68.1"
]
}

View File

@@ -1,12 +1,9 @@
export {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";

View File

@@ -1,21 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
import {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} = await import("./api.js");
resolveImplicitMantleProvider,
} from "./api.js";
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
return vi.fn(() => tokenProvider);
}
const mocks = vi.hoisted(() => ({
getTokenProvider: vi.fn(),
}));
vi.mock("@aws/bedrock-token-generator", () => ({
getTokenProvider: mocks.getTokenProvider,
}));
describe("bedrock mantle discovery", () => {
const originalEnv = process.env;
@@ -23,6 +23,7 @@ describe("bedrock mantle discovery", () => {
beforeEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
mocks.getTokenProvider.mockReset();
resetMantleDiscoveryCacheForTest();
resetIamTokenCacheForTest();
});
@@ -61,15 +62,12 @@ describe("bedrock mantle discovery", () => {
it("generates token from IAM credentials when token generation succeeds", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const token = await generateBearerTokenFromIam({
region: "us-east-1",
tokenProviderFactory,
});
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
expect(token).toBe("bedrock-api-key-generated");
expect(tokenProviderFactory).toHaveBeenCalledWith({
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
region: "us-east-1",
expiresInSeconds: 7200,
});
@@ -78,20 +76,12 @@ describe("bedrock mantle discovery", () => {
it("caches generated IAM tokens within TTL", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
mocks.getTokenProvider.mockReturnValue(tokenProvider);
let now = 1000;
const t1 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
now += 1800_000; // 30 min — within 2hr cache TTL
const t2 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
now += 1800_000; // 30 min — within 1hr cache TTL
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
expect(t1).toEqual(t2);
expect(tokenProvider).toHaveBeenCalledTimes(1);
@@ -102,26 +92,18 @@ describe("bedrock mantle discovery", () => {
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const east = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
const west = await generateBearerTokenFromIam({
region: "us-west-2",
now: () => 2000,
tokenProviderFactory,
});
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
expect(east).toBe("bedrock-api-key-east");
expect(west).toBe("bedrock-api-key-west");
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
region: "us-east-1",
expiresInSeconds: 7200,
});
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
region: "us-west-2",
expiresInSeconds: 7200,
});
@@ -129,44 +111,11 @@ describe("bedrock mantle discovery", () => {
});
it("returns undefined when IAM token generation fails", async () => {
const tokenProviderFactory = vi.fn(() => {
mocks.getTokenProvider.mockImplementation(() => {
throw new Error("no credentials");
});
await expect(
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
).resolves.toBeUndefined();
});
it("getCachedIamToken returns cached token when valid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate a token to populate the cache
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
// Sync read should return the cached token
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
});
it("getCachedIamToken returns undefined when cache is empty", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("getCachedIamToken returns undefined when cache is expired", async () => {
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate with a time far in the past so it's already expired
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
// Current Date.now() is way past that, so it should be expired
expect(getCachedIamToken("us-east-1")).toBeUndefined();
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
});
// ---------------------------------------------------------------------------
@@ -400,26 +349,16 @@ describe("bedrock mantle discovery", () => {
expect(provider?.api).toBe("openai-completions");
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(2);
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).toMatchObject({
api: "anthropic-messages",
reasoning: false,
});
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).not.toHaveProperty("baseUrl");
expect(provider?.models).toHaveLength(1);
});
it("returns null when no auth is available", async () => {
const tokenProviderFactory = vi.fn(() => {
mocks.getTokenProvider.mockImplementation(() => {
throw new Error("no credentials");
});
const provider = await resolveImplicitMantleProvider({
env: {} as NodeJS.ProcessEnv,
tokenProviderFactory,
});
expect(provider).toBeNull();
@@ -427,13 +366,13 @@ describe("bedrock mantle discovery", () => {
it("uses a generated IAM token when no explicit token is set", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
}),
});
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const provider = await resolveImplicitMantleProvider({
env: {
@@ -441,11 +380,10 @@ describe("bedrock mantle discovery", () => {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
tokenProviderFactory,
});
expect(provider).not.toBeNull();
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
expect(tokenProvider).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
@@ -457,52 +395,6 @@ describe("bedrock mantle discovery", () => {
);
});
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 2000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-runtime",
expiresAt: 1000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 5000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-fresh",
expiresAt: 5000 + 7200_000,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("returns null for unsupported regions", async () => {
const provider = await resolveImplicitMantleProvider({
env: {

View File

@@ -18,7 +18,6 @@ const DEFAULT_COST = {
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
// ---------------------------------------------------------------------------
// Mantle region & endpoint helpers
@@ -52,17 +51,6 @@ function isSupportedRegion(region: string): boolean {
// ---------------------------------------------------------------------------
export type MantleBearerTokenProvider = () => Promise<string>;
export type MantleBearerTokenProviderFactory = (opts?: {
region?: string;
expiresInSeconds?: number;
}) => MantleBearerTokenProvider;
async function loadMantleBearerTokenProviderFactory(): Promise<MantleBearerTokenProviderFactory> {
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: MantleBearerTokenProviderFactory;
};
return getTokenProvider;
}
/**
* Resolve a bearer token for Mantle authentication.
@@ -81,22 +69,7 @@ export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env):
/** Token cache for IAM-derived bearer tokens, keyed by region. */
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
}
function getCachedIamTokenEntry(
region: string,
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && cached.expiresAt > now) {
return cached;
}
return undefined;
}
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
/**
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
@@ -107,18 +80,21 @@ function getCachedIamTokenEntry(
export async function generateBearerTokenFromIam(params: {
region: string;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<string | undefined> {
const now = params.now?.() ?? Date.now();
const cached = getCachedIamTokenEntry(params.region, now);
const cached = iamTokenCache.get(params.region);
if (cached) {
if (cached && cached.expiresAt > now) {
return cached.token;
}
try {
const getTokenProvider =
params.tokenProviderFactory ?? (await loadMantleBearerTokenProviderFactory());
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: (opts?: {
region?: string;
expiresInSeconds?: number;
}) => () => Promise<string>;
};
const token = await getTokenProvider({
region: params.region,
expiresInSeconds: 7200, // 2 hours
@@ -134,48 +110,6 @@ export async function generateBearerTokenFromIam(params: {
}
}
/**
* Read a cached IAM bearer token for the given region (sync, no generation).
*
* Returns the token if it exists and has not expired, undefined otherwise.
* Used by Mantle runtime auth and tests to inspect the current cache.
*/
export function getCachedIamToken(region: string): string | undefined {
return getCachedIamTokenEntry(region)?.token;
}
export async function resolveMantleRuntimeBearerToken(params: {
apiKey: string;
env?: NodeJS.ProcessEnv;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<{ apiKey: string; expiresAt?: number } | undefined> {
if (params.apiKey !== MANTLE_IAM_TOKEN_MARKER) {
return { apiKey: params.apiKey };
}
const now = params.now?.() ?? Date.now();
const region = resolveMantleRegion(params.env ?? process.env);
const cached = getCachedIamTokenEntry(region, now);
if (cached) {
return {
apiKey: cached.token,
expiresAt: cached.expiresAt,
};
}
const token = await generateBearerTokenFromIam({
region,
now: params.now,
tokenProviderFactory: params.tokenProviderFactory,
});
if (!token) {
return undefined;
}
const refreshed = getCachedIamTokenEntry(region, now);
return {
apiKey: refreshed?.token ?? token,
expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS,
};
}
/** Reset the IAM token cache (for testing). */
export function resetIamTokenCacheForTest(): void {
iamTokenCache.clear();
@@ -323,10 +257,9 @@ export async function discoverMantleModels(params: {
export async function resolveImplicitMantleProvider(params: {
env?: NodeJS.ProcessEnv;
fetchFn?: typeof fetch;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
const region = resolveMantleRegion(env);
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const explicitBearerToken = resolveMantleBearerToken(env);
if (!isSupportedRegion(region)) {
@@ -335,12 +268,7 @@ export async function resolveImplicitMantleProvider(params: {
}
// Try explicit token first, then generate from IAM credentials
const bearerToken =
explicitBearerToken ??
(await generateBearerTokenFromIam({
region,
tokenProviderFactory: params.tokenProviderFactory,
}));
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
if (!bearerToken) {
return null;
@@ -358,35 +286,12 @@ export async function resolveImplicitMantleProvider(params: {
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
// Append Claude models available on Mantle's Anthropic Messages endpoint.
// Opus 4.7 currently needs the provider-owned bearer-auth path here, but we
// keep reasoning off until the underlying Anthropic transport learns Opus 4.7
// adaptive thinking semantics.
const claudeModels: ModelDefinitionConfig[] = [
{
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages" as const,
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];
return {
baseUrl: `${mantleEndpoint(region)}/v1`,
api: "openai-completions",
auth: "api-key",
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER,
models: allModels,
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
models,
};
}

View File

@@ -1,12 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import bedrockMantlePlugin from "./index.js";
describe("amazon-bedrock-mantle provider plugin", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("registers with correct provider ID and label", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(provider.id).toBe("amazon-bedrock-mantle");
@@ -24,32 +20,5 @@ describe("amazon-bedrock-mantle provider plugin", () => {
expect(
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
).toBeUndefined();
expect(provider.classifyFailoverReason?.({ errorMessage: "overloaded_error" } as never)).toBe(
"overloaded",
);
});
it("provides a custom stream only for Mantle Anthropic models", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(
typeof provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "anthropic.claude-opus-4-7",
model: {
api: "anthropic-messages",
},
} as never),
).toBe("function");
expect(
provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "openai.gpt-oss-120b",
model: {
api: "openai-completions",
},
} as never),
).toBeUndefined();
});
});

View File

@@ -1,106 +0,0 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createMantleAnthropicStreamFn,
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(): Model<Api> {
return {
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
provider: "amazon-bedrock-mantle",
api: "anthropic-messages",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
headers: {
"X-Test": "model-header",
},
reasoning: false,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
} as Model<Api>;
}
function createTestDeps() {
return {
createClient: vi.fn((options: unknown) => ({ options }) as never),
stream: vi.fn(),
};
}
describe("createMantleAnthropicStreamFn", () => {
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
const stream = { kind: "anthropic-stream" };
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue(stream as never);
const result = createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
headers: {
"X-Caller": "caller-header",
},
});
expect(result).toBe(stream);
expect(deps.createClient).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: null,
authToken: "bedrock-bearer-token",
baseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
defaultHeaders: expect.objectContaining({
accept: "application/json",
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
"X-Test": "model-header",
"X-Caller": "caller-header",
}),
}),
);
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
client: expect.objectContaining({
options: expect.objectContaining({
authToken: "bedrock-bearer-token",
}),
}),
thinkingEnabled: false,
}),
);
});
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
temperature: 0.2,
reasoning: "high",
});
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
temperature: undefined,
thinkingEnabled: false,
}),
);
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",
);
expect(
resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/anthropic/"),
).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
});
});

View File

@@ -1,123 +0,0 @@
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamAnthropic } from "@mariozechner/pi-ai/anthropic";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
if (trimmed.endsWith("/anthropic")) {
return trimmed;
}
if (trimmed.endsWith("/v1")) {
return `${trimmed.slice(0, -"/v1".length)}/anthropic`;
}
return `${trimmed}/anthropic`;
}
function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
const merged: Record<string, string> = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return merged;
}
function buildMantleAnthropicBaseOptions(
model: Model<Api>,
options: SimpleStreamOptions | undefined,
apiKey: string,
) {
return {
temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature,
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000),
signal: options?.signal,
apiKey,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};
}
function adjustMaxTokensForThinking(
baseMaxTokens: number,
modelMaxTokens: number,
reasoningLevel: NonNullable<SimpleStreamOptions["reasoning"]>,
customBudgets?: SimpleStreamOptions["thinkingBudgets"],
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
xhigh: 16384,
} as const;
const budgets = { ...defaultBudgets, ...customBudgets };
const minOutputTokens = 1024;
let thinkingBudget = budgets[reasoningLevel];
const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
}
return { maxTokens, thinkingBudget };
}
export function createMantleAnthropicStreamFn(deps?: {
createClient?: (options: AnthropicOptions) => Anthropic;
stream?: typeof streamAnthropic;
}): StreamFn {
return (model, context, options) => {
const apiKey = options?.apiKey ?? "";
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
const stream = deps?.stream ?? streamAnthropic;
const client = createClient({
apiKey: null,
authToken: apiKey,
baseURL: resolveMantleAnthropicBaseUrl(model.baseUrl),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": MANTLE_ANTHROPIC_BETA,
},
model.headers,
options?.headers,
),
});
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
if (!options?.reasoning || requiresDefaultSampling(model.id)) {
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
thinkingEnabled: false,
});
}
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
);
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
maxTokens: adjusted.maxTokens,
thinkingEnabled: true,
thinkingBudgetTokens: adjusted.thinkingBudget,
});
};
}

View File

@@ -5,9 +5,7 @@
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "0.81.0",
"@aws/bedrock-token-generator": "^1.1.0",
"@mariozechner/pi-ai": "0.68.1"
"@aws/bedrock-token-generator": "^1.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -3,9 +3,7 @@ import {
mergeImplicitMantleProvider,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
const providerId = "amazon-bedrock-mantle";
@@ -33,21 +31,14 @@ export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
},
},
resolveConfigApiKey: ({ env }) =>
resolveMantleBearerToken(env) ? "env:AWS_BEARER_TOKEN_BEDROCK" : undefined,
prepareRuntimeAuth: async ({ apiKey, env }) =>
await resolveMantleRuntimeBearerToken({
apiKey,
env,
}),
createStreamFn: ({ model }) =>
model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined,
resolveMantleBearerToken(env) ? "AWS_BEARER_TOKEN_BEDROCK" : undefined,
matchesContextOverflowError: ({ errorMessage }) =>
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
classifyFailoverReason: ({ errorMessage }) => {
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
return "rate_limit";
}
if (/overloaded|503|service.*unavailable/i.test(errorMessage)) {
if (/overloaded|503/i.test(errorMessage)) {
return "overloaded";
}
return undefined;

View File

@@ -1,7 +0,0 @@
{
"specs": [
"@aws-sdk/client-bedrock-runtime@3.1032.0",
"@aws-sdk/client-bedrock@3.1032.0",
"@aws-sdk/credential-provider-node@3.972.32"
]
}

View File

@@ -87,7 +87,7 @@ describe("bedrock discovery", () => {
name: "Claude 3.7 Sonnet",
reasoning: false,
input: ["text", "image"],
contextWindow: 200000,
contextWindow: 32000,
maxTokens: 4096,
});
});
@@ -104,11 +104,7 @@ describe("bedrock discovery", () => {
});
it("uses configured defaults for context and max tokens", async () => {
mockSingleActiveSummary({
modelId: "example.unknown-text-v1:0",
modelName: "Example Unknown Text",
providerName: "example",
});
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -118,69 +114,6 @@ describe("bedrock discovery", () => {
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
});
it("keeps the conservative fallback for unknown inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.example.unknown-text-v1:0",
inferenceProfileName: "JP Example Unknown Text",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/example.unknown-text-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "jp.example.unknown-text-v1:0",
contextWindow: 32000,
maxTokens: 4096,
input: ["text"],
});
});
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.anthropic.claude-sonnet-4-6-v1:0",
inferenceProfileName: "JP Claude Sonnet 4.6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expect(models[0]).toMatchObject({
id: "jp.anthropic.claude-sonnet-4-6-v1:0",
contextWindow: 1_000_000,
});
});
it("caches results when refreshInterval is enabled", async () => {
mockSingleActiveSummary();
@@ -319,7 +252,7 @@ describe("bedrock discovery", () => {
expect(usProfile).toMatchObject({
name: "US Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
contextWindow: 1000000,
contextWindow: 32000,
maxTokens: 4096,
});
expect(euProfile).toMatchObject({ input: ["text", "image"] });
@@ -423,43 +356,11 @@ describe("bedrock discovery", () => {
expect(profile).toMatchObject({
id: "us.my-prod-profile",
input: ["text", "image"],
contextWindow: 1000000,
contextWindow: 32000,
maxTokens: 4096,
});
});
it("uses the resolved base model id for application-profile context fallback", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.my-prod-profile",
inferenceProfileName: "Prod Claude Profile",
status: "ACTIVE",
type: "APPLICATION",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models[0]).toMatchObject({
id: "us.my-prod-profile",
contextWindow: 1_000_000,
maxTokens: 4096,
input: ["text"],
});
});
it("merges implicit Bedrock models into explicit provider overrides", () => {
expect(
mergeImplicitBedrockProvider({
@@ -532,63 +433,4 @@ describe("bedrock discovery", () => {
expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com");
expect(sendMock).toHaveBeenCalledTimes(4);
});
// Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix
it("resolves au. and apac. prefixes for regional inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "au.anthropic.claude-sonnet-4-6",
inferenceProfileName: "AU Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-southeast-2::inference-profile/au.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [], // no ARNs — forces the prefix-regex fallback
},
{
inferenceProfileId: "apac.anthropic.claude-sonnet-4-6",
inferenceProfileName: "APAC Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-northeast-1::inference-profile/apac.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [],
},
],
});
const models = await discoverBedrockModels({ region: "ap-southeast-2", clientFactory });
// Foundation model + 2 regional inference profiles
expect(models).toHaveLength(3);
const auProfile = models.find((m) => m.id === "au.anthropic.claude-sonnet-4-6");
expect(auProfile).toMatchObject({
id: "au.anthropic.claude-sonnet-4-6",
name: "AU Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
const apacProfile = models.find((m) => m.id === "apac.anthropic.claude-sonnet-4-6");
expect(apacProfile).toMatchObject({
id: "apac.anthropic.claude-sonnet-4-6",
name: "APAC Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
});
});

View File

@@ -21,121 +21,8 @@ import {
const log = createSubsystemLogger("bedrock-discovery");
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
const DEFAULT_CONTEXT_WINDOW = 32_000;
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
// ---------------------------------------------------------------------------
// Known model context windows (Bedrock API does not expose token limits)
// ---------------------------------------------------------------------------
/**
* Bedrock's ListFoundationModels and GetFoundationModel APIs return no token
* limit information — only model ID, name, modalities, and lifecycle status.
* There is currently no Bedrock API to discover context windows or max output
* tokens programmatically.
*
* This map provides correct context window values for known models so that
* session management, compaction thresholds, and context overflow detection
* work correctly. If AWS adds token metadata to the API in the future, this
* table should become a fallback rather than the primary source.
*
* Inference profile prefixes (us., eu., ap., global.) are stripped before lookup.
*
* Sources: https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
* https://platform.claude.com/docs/en/about-claude/models
*/
const KNOWN_CONTEXT_WINDOWS: Record<string, number> = {
// Anthropic Claude
"anthropic.claude-3-7-sonnet-20250219-v1:0": 200_000,
"anthropic.claude-opus-4-7": 1_000_000,
"anthropic.claude-opus-4-6-v1": 1_000_000,
"anthropic.claude-opus-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-6": 1_000_000,
"anthropic.claude-sonnet-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-5-20250929-v1:0": 200_000,
"anthropic.claude-sonnet-4-20250514-v1:0": 200_000,
"anthropic.claude-opus-4-5-20251101-v1:0": 200_000,
"anthropic.claude-opus-4-1-20250805-v1:0": 200_000,
"anthropic.claude-haiku-4-5-20251001-v1:0": 200_000,
"anthropic.claude-3-5-haiku-20241022-v1:0": 200_000,
"anthropic.claude-3-haiku-20240307-v1:0": 200_000,
// Amazon Nova
"amazon.nova-premier-v1:0": 1_000_000,
"amazon.nova-pro-v1:0": 300_000,
"amazon.nova-lite-v1:0": 300_000,
"amazon.nova-micro-v1:0": 128_000,
"amazon.nova-2-lite-v1:0": 300_000,
// MiniMax
"minimax.minimax-m2.5": 1_000_000,
"minimax.minimax-m2.1": 1_000_000,
"minimax.minimax-m2": 1_000_000,
// Meta Llama 4
"meta.llama4-maverick-17b-instruct-v1:0": 1_000_000,
"meta.llama4-scout-17b-instruct-v1:0": 512_000,
// Meta Llama 3
"meta.llama3-3-70b-instruct-v1:0": 128_000,
"meta.llama3-2-90b-instruct-v1:0": 128_000,
"meta.llama3-2-11b-instruct-v1:0": 128_000,
"meta.llama3-2-3b-instruct-v1:0": 128_000,
"meta.llama3-2-1b-instruct-v1:0": 128_000,
"meta.llama3-1-405b-instruct-v1:0": 128_000,
"meta.llama3-1-70b-instruct-v1:0": 128_000,
"meta.llama3-1-8b-instruct-v1:0": 128_000,
// NVIDIA Nemotron
"nvidia.nemotron-super-3-120b": 256_000,
"nvidia.nemotron-nano-3-30b": 128_000,
"nvidia.nemotron-nano-12b-v2": 128_000,
"nvidia.nemotron-nano-9b-v2": 128_000,
// Mistral
"mistral.mistral-large-3-675b-instruct": 128_000,
"mistral.mistral-large-2407-v1:0": 128_000,
"mistral.mistral-small-2402-v1:0": 32_000,
// DeepSeek
"deepseek.r1-v1:0": 128_000,
"deepseek.v3.2": 128_000,
// Cohere
"cohere.command-r-plus-v1:0": 128_000,
"cohere.command-r-v1:0": 128_000,
// AI21
"ai21.jamba-1-5-large-v1:0": 256_000,
"ai21.jamba-1-5-mini-v1:0": 256_000,
// Google Gemma
"google.gemma-3-27b-it": 128_000,
"google.gemma-3-12b-it": 128_000,
"google.gemma-3-4b-it": 128_000,
// GLM
"zai.glm-5": 128_000,
"zai.glm-4.7": 128_000,
"zai.glm-4.7-flash": 128_000,
// Qwen
"qwen.qwen3-coder-next": 256_000,
"qwen.qwen3-coder-30b-a3b-v1:0": 256_000,
"qwen.qwen3-32b-v1:0": 128_000,
"qwen.qwen3-vl-235b-a22b": 128_000,
};
/**
* Resolve the real context window for a Bedrock model ID.
* Strips inference profile prefixes (us., eu., ap., global.) before lookup.
*/
function resolveKnownContextWindow(modelId: string): number | undefined {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
const candidates = [modelId, stripped];
for (const candidate of candidates) {
if (KNOWN_CONTEXT_WINDOWS[candidate] !== undefined) {
return KNOWN_CONTEXT_WINDOWS[candidate];
}
const withoutVersionSuffix = candidate.replace(/:0$/, "");
if (
withoutVersionSuffix !== candidate &&
KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix] !== undefined
) {
return KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix];
}
}
return undefined;
}
const DEFAULT_COST = {
input: 0,
output: 0,
@@ -276,7 +163,7 @@ function toModelDefinition(
reasoning: inferReasoningSupport(summary),
input: mapInputModalities(summary),
cost: DEFAULT_COST,
contextWindow: resolveKnownContextWindow(id) ?? defaults.contextWindow,
contextWindow: defaults.contextWindow,
maxTokens: defaults.maxTokens,
};
}
@@ -305,7 +192,7 @@ function resolveBaseModelId(profile: InferenceProfileSummary): string | undefine
}
if (profile.type === "SYSTEM_DEFINED") {
const id = profile.inferenceProfileId ?? "";
const prefixMatch = /^(?:us|eu|ap|apac|au|jp|global)\.(.+)$/i.exec(id);
const prefixMatch = /^(?:us|eu|ap|jp|global)\.(.+)$/i.exec(id);
if (prefixMatch) {
return prefixMatch[1];
}
@@ -395,10 +282,7 @@ function resolveInferenceProfiles(
reasoning: baseModel?.reasoning ?? false,
input: baseModel?.input ?? ["text"],
cost: baseModel?.cost ?? DEFAULT_COST,
contextWindow:
baseModel?.contextWindow ??
resolveKnownContextWindow(baseModelId ?? profile.inferenceProfileId ?? "") ??
defaults.contextWindow,
contextWindow: baseModel?.contextWindow ?? defaults.contextWindow,
maxTokens: baseModel?.maxTokens ?? defaults.maxTokens,
});
}

View File

@@ -1,43 +1,12 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../src/config/config.js";
import { buildPluginApi } from "../../src/plugins/api-builder.js";
import type { PluginRuntime } from "../../src/plugins/runtime/types.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import amazonBedrockPlugin from "./index.js";
type InferenceProfileResult = { models?: Array<{ modelArn?: string }> } | Error;
const inferenceProfileResults: InferenceProfileResult[] = [];
const bedrockClientConfigs: Array<Record<string, unknown>> = [];
const sendGetInferenceProfile = vi.fn(async () => {
const next = inferenceProfileResults.shift();
if (next instanceof Error) {
throw next;
}
return next ?? { models: [] };
});
vi.mock("@aws-sdk/client-bedrock", () => {
class GetInferenceProfileCommand {
constructor(readonly input: { inferenceProfileIdentifier: string }) {}
}
class BedrockClient {
constructor(config: Record<string, unknown> = {}) {
bedrockClientConfigs.push(config);
}
send = sendGetInferenceProfile;
}
return {
BedrockClient,
GetInferenceProfileCommand,
};
});
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
@@ -89,22 +58,6 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
id: ANTHROPIC_MODEL,
} as never;
const APP_INFERENCE_PROFILE_ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
api: "openai-completions",
provider: "amazon-bedrock",
id: APP_INFERENCE_PROFILE_ARN,
} as never;
function makeAppInferenceProfileDescriptor(modelId: string): never {
return {
api: "openai-completions",
provider: "amazon-bedrock",
id: modelId,
} as never;
}
/**
* Call wrapStreamFn and then invoke the returned stream function, capturing
* the payload via the onPayload hook that streamWithPayloadPatch installs.
@@ -139,12 +92,6 @@ function callWrappedStream(
}
describe("amazon-bedrock provider plugin", () => {
beforeEach(() => {
inferenceProfileResults.length = 0;
bedrockClientConfigs.length = 0;
sendGetInferenceProfile.mockClear();
});
it("marks Claude 4.6 Bedrock models as adaptive by default", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
@@ -355,347 +302,4 @@ describe("amazon-bedrock provider plugin", () => {
expect(result).toMatchObject({ cacheRetention: "none" });
});
});
describe("application inference profile cache point injection", () => {
/**
* Invoke wrapStreamFn with a payload containing system/messages, then
* trigger onPayload to capture the patched payload.
*/
async function callWrappedStreamWithPayload(
provider: RegisteredProviderPlugin,
modelId: string,
modelDescriptor: never,
options: Record<string, unknown>,
payload: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.(
modelDescriptor,
{ messages: [] } as never,
options,
) as unknown as Record<string, unknown>;
if (typeof result?.onPayload === "function") {
await (
result.onPayload as (p: Record<string, unknown>, model: unknown) => Promise<unknown>
)(payload, modelDescriptor);
}
return payload;
}
it("injects cache points for application inference profile ARNs", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
const lastUserContent = messages[0].content;
expect(lastUserContent).toHaveLength(2);
expect(lastUserContent[1]).toEqual({ cachePoint: { type: "default" } });
});
it("uses long TTL when cacheRetention is 'long'", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "long" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system[1]).toEqual({ cachePoint: { type: "default", ttl: "1h" } });
});
it("does not inject cache points when cacheRetention is 'none'", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "none" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("does not double-inject cache points if already present", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "Hello" }, { cachePoint: { type: "default" } }] },
],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
expect(messages[0].content).toHaveLength(2);
});
it("does not inject cache points for regular Anthropic model IDs (pi-ai handles them)", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
// Regular model IDs contain "claude" so pi-ai handles caching natively.
// wrapStreamFn should not install an onPayload hook for these.
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: ANTHROPIC_MODEL,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.(ANTHROPIC_MODEL_DESCRIPTOR, { messages: [] } as never, {
cacheRetention: "short",
}) as unknown as Record<string, unknown>;
// For regular Anthropic models, no onPayload should be installed for cache injection.
if (typeof result?.onPayload === "function") {
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
}
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("does not inject cache points for older Claude models not in pi-ai's cache list", async () => {
const provider = await registerWithConfig(undefined);
const oldClaudeModel = "anthropic.claude-3-opus-20240229-v1:0";
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
// Claude 3 Opus is not in pi-ai's supportsPromptCaching list, but it's
// also not an application inference profile — we should not inject.
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: oldClaudeModel,
streamFn: spyStreamFn,
} as never);
const result = wrapped?.({ id: oldClaudeModel } as never, { messages: [] } as never, {
cacheRetention: "short",
}) as unknown as Record<string, unknown>;
if (typeof result?.onPayload === "function") {
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
}
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
});
it("defaults to 'short' cache retention when not explicitly set", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{},
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system).toHaveLength(2);
// Default is "short" which means no ttl field
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
});
it("injects cache point only on last USER message", async () => {
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [
{ role: "user", content: [{ text: "First question" }] },
{ role: "assistant", content: [{ text: "Answer" }] },
{ role: "user", content: [{ text: "Follow-up" }] },
],
};
await callWrappedStreamWithPayload(
provider,
APP_INFERENCE_PROFILE_ARN,
APP_INFERENCE_PROFILE_DESCRIPTOR,
{ cacheRetention: "short" },
payload,
);
const messages = payload.messages as Array<{
role: string;
content: Array<Record<string, unknown>>;
}>;
// First user message should NOT have a cache point
expect(messages[0].content).toHaveLength(1);
// Assistant message untouched
expect(messages[1].content).toHaveLength(1);
// Last user message should have a cache point
expect(messages[2].content).toHaveLength(2);
expect(messages[2].content[1]).toEqual({ cachePoint: { type: "default" } });
});
it("injects cache points for opaque application inference profile ARNs after profile lookup", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459da";
inferenceProfileResults.push({
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
payload,
);
const system = payload.system as Array<Record<string, unknown>>;
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
expect(sendGetInferenceProfile).toHaveBeenCalledTimes(1);
expect(bedrockClientConfigs).toEqual([{ region: "us-east-1" }]);
});
it("does not inject cache points when any resolved profile target is not cacheable", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459db";
inferenceProfileResults.push({
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-opus-20240229-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const payload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
payload,
);
expect(payload.system).toEqual([{ text: "You are helpful." }]);
expect(payload.messages).toEqual([{ role: "user", content: [{ text: "Hello" }] }]);
});
it("retries opaque profile lookup after a transient failure instead of caching the fallback", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/z27qyso459dc";
inferenceProfileResults.push(new Error("throttled"), {
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
],
});
const provider = await registerWithConfig(undefined);
const firstPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
const secondPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello again" }] }],
};
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
firstPayload,
);
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
secondPayload,
);
expect(firstPayload.system).toEqual([{ text: "You are helpful." }]);
expect(secondPayload.system).toEqual([
{ text: "You are helpful." },
{ cachePoint: { type: "default" } },
]);
expect(sendGetInferenceProfile).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -62,179 +62,6 @@ function createGuardrailWrapStreamFn(
};
}
/**
* Mirrors the shipped pi-ai Bedrock `supportsPromptCaching` matcher.
* Keep this in sync with node_modules/@mariozechner/pi-ai/dist/providers/amazon-bedrock.js.
*/
function matchesPiAiPromptCachingModelId(modelId: string): boolean {
const id = modelId.toLowerCase();
if (!id.includes("claude")) {
return false;
}
// Claude 4.x
if (id.includes("-4-") || id.includes("-4.")) {
return true;
}
// Claude 3.7 Sonnet
if (id.includes("claude-3-7-sonnet")) {
return true;
}
// Claude 3.5 Haiku
if (id.includes("claude-3-5-haiku")) {
return true;
}
return false;
}
function piAiWouldInjectCachePoints(modelId: string): boolean {
return matchesPiAiPromptCachingModelId(modelId);
}
/**
* Detect Bedrock application inference profile ARNs — these are the only IDs
* where pi-ai's model-name-based checks fail because the ARN is opaque.
* System-defined profiles (us., eu., global.) and base model IDs always
* contain the model name and are handled by pi-ai natively.
*/
const BEDROCK_APP_INFERENCE_PROFILE_RE =
/^arn:aws(-cn|-us-gov)?:bedrock:.*:application-inference-profile\//i;
function isBedrockAppInferenceProfile(modelId: string): boolean {
return BEDROCK_APP_INFERENCE_PROFILE_RE.test(modelId);
}
/**
* pi-ai's internal `supportsPromptCaching` checks `model.id` for specific Claude
* model name patterns, which fails for application inference profile ARNs (opaque
* IDs that may not contain the model name). When OpenClaw's `isAnthropicBedrockModel`
* identifies the model but pi-ai won't inject cache points, we do it via onPayload.
*
* Gated to application inference profile ARNs only — regular Claude model IDs and
* system-defined inference profiles (us.anthropic.claude-*) are left to pi-ai.
*/
function needsCachePointInjection(modelId: string): boolean {
// Only target application inference profile ARNs.
if (!isBedrockAppInferenceProfile(modelId)) {
return false;
}
// If pi-ai would already inject cache points, skip.
if (piAiWouldInjectCachePoints(modelId)) {
return false;
}
// Check if OpenClaw identifies this as an Anthropic model via the ARN heuristic.
if (isAnthropicBedrockModel(modelId)) {
return true;
}
return false;
}
/**
* Extract the region from a Bedrock ARN.
* e.g. "arn:aws:bedrock:us-east-1:123:application-inference-profile/abc" → "us-east-1"
*/
function extractRegionFromArn(arn: string): string | undefined {
const parts = arn.split(":");
// ARN format: arn:partition:service:region:account:resource
return parts.length >= 4 && parts[3] ? parts[3] : undefined;
}
/**
* Check if a resolved foundation model ARN supports prompt caching using the
* same matcher pi-ai uses for direct model IDs.
*/
function resolvedModelSupportsCaching(modelArn: string): boolean {
return matchesPiAiPromptCachingModelId(modelArn);
}
/**
* Resolve the underlying foundation model for an application inference profile
* via GetInferenceProfile. Results are cached so we only call the API once per
* profile ARN. Returns true if the underlying model supports prompt caching.
*
* Region is extracted from the profile ARN itself to avoid mismatches when
* the OpenClaw config region differs from the profile's home region.
*/
const appProfileCacheEligibleCache = new Map<string, boolean>();
async function resolveAppProfileCacheEligible(
modelId: string,
fallbackRegion: string | undefined,
): Promise<boolean> {
if (appProfileCacheEligibleCache.has(modelId)) {
return appProfileCacheEligibleCache.get(modelId)!;
}
try {
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const region = extractRegionFromArn(modelId) ?? fallbackRegion;
const client = new BedrockClient(region ? { region } : {});
const resp = await client.send(
new GetInferenceProfileCommand({ inferenceProfileIdentifier: modelId }),
);
const models = resp.models ?? [];
const eligible =
models.length > 0 &&
models.every((m: { modelArn?: string }) => resolvedModelSupportsCaching(m.modelArn ?? ""));
appProfileCacheEligibleCache.set(modelId, eligible);
return eligible;
} catch {
// Transient failures (throttling, network, IAM) should not be cached —
// return the heuristic fallback but allow retry on the next request.
return isAnthropicBedrockModel(modelId);
}
}
type BedrockCachePoint = { cachePoint: { type: "default"; ttl?: string } };
type BedrockContentBlock = Record<string, unknown>;
type BedrockMessage = { role?: string; content?: BedrockContentBlock[] };
function hasCachePoint(blocks: BedrockContentBlock[] | undefined): boolean {
return blocks?.some((b) => b.cachePoint != null) === true;
}
function makeCachePoint(cacheRetention: string | undefined): BedrockCachePoint {
return {
cachePoint: {
type: "default",
...(cacheRetention === "long" ? { ttl: "1h" } : {}),
},
};
}
/**
* Inject Bedrock Converse cache points into the payload when pi-ai skipped them
* because it didn't recognize the model ID (application inference profiles).
*/
function injectBedrockCachePoints(
payload: Record<string, unknown>,
cacheRetention: string | undefined,
): void {
if (!cacheRetention || cacheRetention === "none") {
return;
}
const point = makeCachePoint(cacheRetention);
// Inject into system prompt if missing.
const system = payload.system as BedrockContentBlock[] | undefined;
if (Array.isArray(system) && system.length > 0 && !hasCachePoint(system)) {
system.push(point);
}
// Inject into the last user message if missing.
// Bedrock Converse uses lowercase roles ("user" / "assistant").
const messages = payload.messages as BedrockMessage[] | undefined;
if (Array.isArray(messages) && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user" && Array.isArray(msg.content)) {
if (!hasCachePoint(msg.content)) {
msg.content.push(point);
}
break;
}
}
}
}
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Keep registration-local constants inside the function so partial module
// initialization during test bootstrap cannot trip TDZ reads.
@@ -254,17 +81,8 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) => {
if (isAnthropicBedrockModel(modelId)) {
return streamFn;
}
// For app inference profiles with opaque IDs, don't force cacheRetention: "none"
// yet — we may resolve them as Claude later via GetInferenceProfile.
if (isBedrockAppInferenceProfile(modelId)) {
return streamFn;
}
return createBedrockNoCacheWrapper(streamFn);
};
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);
const cacheWrapStreamFn =
guardrail?.guardrailIdentifier && guardrail?.guardrailVersion
@@ -343,61 +161,23 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Apply cache + guardrail wrapping.
const wrapped = cacheWrapStreamFn({ modelId, streamFn });
const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl);
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId);
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
const heuristicMatch = needsCachePointInjection(modelId);
if (!region && !mayNeedCacheInjection) {
if (!region) {
return wrapped;
}
// Wrap to inject the region into every stream call so pi-ai's Bedrock
// client connects to the right region for inference profile IDs.
const underlying = wrapped ?? streamFn;
if (!underlying) {
return wrapped;
}
return (streamModel, context, options) => {
const merged = Object.assign({}, options, region ? { region } : {});
if (!mayNeedCacheInjection) {
return underlying(streamModel, context, merged);
}
// Use the cacheRetention from options if explicitly set.
// When undefined, default to "short" to match pi-ai's internal default.
// Note: if the user set cacheRetention: "none" but the opaque ARN wasn't
// recognized by resolveAnthropicCacheRetentionFamily, the value may have
// been dropped upstream. This is a known limitation — the proper fix is
// to also teach resolveAnthropicCacheRetentionFamily about opaque profiles
// (tracked separately). In practice, users with app inference profiles
// want caching enabled, so defaulting to "short" is the safer behavior.
const cacheRetention =
typeof merged.cacheRetention === "string" ? merged.cacheRetention : "short";
if (heuristicMatch) {
// Fast path: ARN heuristic already identified this as Claude.
return streamWithPayloadPatch(underlying, streamModel, context, merged, (payload) => {
injectBedrockCachePoints(payload, cacheRetention);
});
}
// Slow path: opaque profile ID — resolve underlying model via API (cached).
// pi-ai's onPayload supports async, so we await the resolution inline.
const originalOnPayload = merged.onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
return underlying(streamModel, context, {
...merged,
onPayload: async (payload: unknown, payloadModel: unknown) => {
const eligible = await resolveAppProfileCacheEligible(modelId, region);
if (eligible && payload && typeof payload === "object") {
injectBedrockCachePoints(payload as Record<string, unknown>, cacheRetention);
}
return originalOnPayload?.(payload, payloadModel);
},
});
// pi-ai's bedrock provider reads `options.region` at runtime but the
// StreamFn type does not declare it. Merge via Object.assign to avoid
// an unsafe type assertion.
const merged = Object.assign({}, options, { region });
return underlying(streamModel, context, merged);
};
},
matchesContextOverflowError: ({ errorMessage }) =>

View File

@@ -1,3 +0,0 @@
{
"specs": ["@mariozechner/pi-ai@0.68.1"]
}

View File

@@ -107,21 +107,6 @@ describe("normalizeClaudeBackendConfig", () => {
"--permission-mode",
"bypassPermissions",
]);
expect(normalized.output).toBe("jsonl");
expect(normalized.liveSession).toBe("claude-stdio");
expect(normalized.input).toBe("stdin");
});
it("does not infer live stdio when explicit transport overrides are incompatible", () => {
const normalized = normalizeClaudeBackendConfig({
command: "claude",
output: "json",
input: "arg",
});
expect(normalized.output).toBe("json");
expect(normalized.liveSession).toBeUndefined();
expect(normalized.input).toBe("arg");
});
it("is wired through the anthropic cli backend normalize hook", () => {
@@ -144,16 +129,12 @@ describe("normalizeClaudeBackendConfig", () => {
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.resumeArgs).toContain("--setting-sources");
expect(normalized?.resumeArgs).toContain("user");
expect(normalized?.liveSession).toBe("claude-stdio");
});
it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => {
const backend = buildAnthropicCliBackend();
expect(backend.config.env).toBeUndefined();
expect(backend.config.liveSession).toBe("claude-stdio");
expect(backend.config.output).toBe("jsonl");
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expect(backend.config.resumeArgs).toContain("--setting-sources");

View File

@@ -135,15 +135,9 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
const output = config.output ?? "jsonl";
const input = config.input ?? "stdin";
return {
...config,
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)),
resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)),
output,
liveSession:
config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined),
input,
};
}

View File

@@ -223,8 +223,6 @@ describe("anthropic provider replay hooks", () => {
id: "claude-opus-4-7",
api: "anthropic-messages",
reasoning: true,
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
expect(
provider.resolveThinkingProfile?.({
@@ -254,37 +252,6 @@ describe("anthropic provider replay hooks", () => {
).toBe(false);
});
it("normalizes exact claude opus 4.7 variants to 1M context", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
for (const [runtimeProvider, modelId] of [
["anthropic", "claude-opus-4-7"],
["claude-cli", "claude-opus-4.7-20260219"],
] as const) {
expect(
provider.normalizeResolvedModel?.({
provider: runtimeProvider,
modelId,
model: {
id: modelId,
name: "Claude Opus 4.7",
provider: runtimeProvider,
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 32_000,
},
} as never),
).toMatchObject({
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
}
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({

View File

@@ -4,7 +4,6 @@ import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
@@ -45,7 +44,6 @@ const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-7";
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
const ANTHROPIC_OPUS_47_CONTEXT_TOKENS = 1_048_576;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
@@ -284,75 +282,6 @@ function supportsAnthropicAdaptiveThinking(modelId: string): boolean {
return shouldUseAnthropicAdaptiveThinkingDefault(modelId) || isAnthropicOpus47Model(modelId);
}
function hasConfiguredModelContextOverride(
config: ProviderNormalizeResolvedModelContext["config"],
provider: string,
modelId: string,
): boolean {
const providers = config?.models?.providers;
if (!providers || typeof providers !== "object") {
return false;
}
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
for (const [providerId, providerConfig] of Object.entries(providers)) {
if (normalizeLowercaseStringOrEmpty(providerId) !== normalizedProvider) {
continue;
}
if (!Array.isArray(providerConfig?.models)) {
continue;
}
for (const model of providerConfig.models) {
if (
normalizeLowercaseStringOrEmpty(typeof model?.id === "string" ? model.id : "") !==
normalizedModelId
) {
continue;
}
if (
(typeof model?.contextTokens === "number" && model.contextTokens > 0) ||
(typeof model?.contextWindow === "number" && model.contextWindow > 0)
) {
return true;
}
}
}
return false;
}
function applyAnthropicOpus47ContextWindow(params: {
config?: ProviderNormalizeResolvedModelContext["config"];
provider: string;
modelId: string;
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
if (!isAnthropicOpus47Model(params.modelId)) {
return undefined;
}
if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) {
return undefined;
}
const nextContextWindow = Math.max(
params.model.contextWindow ?? 0,
ANTHROPIC_OPUS_47_CONTEXT_TOKENS,
);
const nextContextTokens =
typeof params.model.contextTokens === "number"
? Math.max(params.model.contextTokens, ANTHROPIC_OPUS_47_CONTEXT_TOKENS)
: ANTHROPIC_OPUS_47_CONTEXT_TOKENS;
if (
nextContextWindow === params.model.contextWindow &&
nextContextTokens === params.model.contextTokens
) {
return undefined;
}
return {
...params.model,
contextWindow: nextContextWindow,
contextTokens: nextContextTokens,
};
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
@@ -557,21 +486,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
normalizeConfig: ({ provider, providerConfig }) =>
normalizeAnthropicProviderConfigForProvider({ provider, providerConfig }),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => {
const model = resolveAnthropicForwardCompatModel(ctx);
if (!model) {
return undefined;
}
return (
applyAnthropicOpus47ContextWindow({
config: ctx.config,
provider: ctx.provider,
modelId: ctx.modelId,
model,
}) ?? model
);
},
normalizeResolvedModel: (ctx) => applyAnthropicOpus47ContextWindow(ctx),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
resolveSyntheticAuth: ({ provider }) =>
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()

View File

@@ -35,13 +35,7 @@
"imessage"
],
"systemImage": "bubble.left.and.text.bubble.right",
"order": 75,
"cliAddOptions": [
{
"flags": "--webhook-path <path>",
"description": "BlueBubbles webhook path"
}
]
"order": 75
},
"install": {
"npmSpec": "@openclaw/bluebubbles",

View File

@@ -1,9 +1,14 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
getSessionBindingService,
isPluginOwnedSessionBindingRecord,
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import {
deriveLastRoutePolicy,
resolveAgentIdFromSessionKey,
resolveAgentRoute,
} from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveBlueBubblesInboundConversationId } from "./conversation-id.js";
@@ -48,21 +53,31 @@ export function resolveBlueBubblesConversationRoute(params: {
},
}).route;
const runtimeRoute = resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "bluebubbles",
accountId: params.accountId,
conversationId,
},
const runtimeBinding = getSessionBindingService().resolveByConversation({
channel: "bluebubbles",
accountId: params.accountId,
conversationId,
});
route = runtimeRoute.route;
if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) {
logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`);
} else if (runtimeRoute.boundSessionKey) {
logVerbose(
`bluebubbles: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`,
);
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
if (!runtimeBinding || !boundSessionKey) {
return route;
}
return route;
getSessionBindingService().touch(runtimeBinding.bindingId);
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`);
return route;
}
logVerbose(`bluebubbles: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
return {
...route,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel",
};
}

View File

@@ -1,3 +0,0 @@
{
"specs": ["@sinclair/typebox@0.34.49"]
}

View File

@@ -7,9 +7,6 @@ import {
registerBrowserPlugin,
} from "./plugin-registration.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import setupPlugin from "./setup-api.js";
type BrowserAutoEnableProbe = Parameters<OpenClawPluginApi["registerAutoEnableProbe"]>[0];
const runtimeApiMocks = vi.hoisted(() => ({
createBrowserPluginService: vi.fn(() => ({ id: "browser-control", start: vi.fn() })),
@@ -54,22 +51,6 @@ function createApi() {
return { api, registerCli, registerGatewayMethod, registerService, registerTool };
}
function registerBrowserAutoEnableProbe(): BrowserAutoEnableProbe {
const probes: BrowserAutoEnableProbe[] = [];
setupPlugin.register(
createTestPluginApi({
registerAutoEnableProbe(probe) {
probes.push(probe);
},
}),
);
const probe = probes[0];
if (!probe) {
throw new Error("expected browser setup plugin to register an auto-enable probe");
}
return probe;
}
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
@@ -105,18 +86,4 @@ describe("browser plugin", () => {
agentSessionKey: "agent:main:webchat:direct:123",
});
});
it("declares setup auto-enable reasons for browser config surfaces", () => {
const probe = registerBrowserAutoEnableProbe();
expect(probe({ config: { browser: { defaultProfile: "openclaw" } }, env: {} })).toBe(
"browser configured",
);
expect(probe({ config: { tools: { alsoAllow: ["browser"] } }, env: {} })).toBe(
"browser tool referenced",
);
expect(
probe({ config: { browser: { defaultProfile: "openclaw", enabled: false } }, env: {} }),
).toBeNull();
});
});

View File

@@ -76,7 +76,7 @@ export async function requirePwAi(
501,
[
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Repair the bundled browser plugin runtime dependencies so playwright-core is installed, then restart the gateway. In Docker, also install Chromium with the bundled playwright-core CLI.",
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
"Docs: /tools/browser#playwright-requirement",
].join("\n"),
);

View File

@@ -9,50 +9,11 @@ export function installAgentContractHooks() {
installBrowserControlServerHooks();
}
function isTransientStartupFetchError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const record = error as { code?: unknown; cause?: unknown };
if (record.code === "ECONNRESET" || record.code === "ECONNREFUSED") {
return true;
}
return isTransientStartupFetchError(record.cause);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postStartWithRetry(params: {
fetch: ReturnType<typeof getBrowserTestFetch>;
url: string;
}): Promise<void> {
const delaysMs = [0, 25, 50, 100, 200] as const;
let lastError: unknown;
for (const delayMs of delaysMs) {
if (delayMs > 0) {
await sleep(delayMs);
}
try {
const response = await params.fetch(params.url, { method: "POST" });
await response.json();
return;
} catch (error) {
lastError = error;
if (!isTransientStartupFetchError(error)) {
throw error;
}
}
}
throw lastError;
}
export async function startServerAndBase(): Promise<string> {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
await postStartWithRetry({ fetch: realFetch, url: `${base}/start` });
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
}

View File

@@ -1,3 +0,0 @@
{
"specs": ["@mariozechner/pi-coding-agent@0.68.1", "ws@^8.20.0", "zod@^4.3.6"]
}

View File

@@ -34,11 +34,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["yolo", "guardian"],
"default": "yolo"
},
"transport": {
"type": "string",
"enum": ["stdio", "websocket"],
@@ -83,7 +78,7 @@
"enum": ["user", "guardian_subagent"],
"default": "user"
},
"serviceTier": { "type": ["string", "null"], "enum": ["fast", "flex", null] }
"serviceTier": { "type": "string" }
}
}
}
@@ -107,11 +102,6 @@
"help": "Runtime controls for connecting to Codex app-server.",
"advanced": true
},
"appServer.mode": {
"label": "Execution Mode",
"help": "Use yolo for unchained local execution or guardian for Codex guardian-reviewed approvals.",
"advanced": true
},
"appServer.transport": {
"label": "Transport",
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",
@@ -165,7 +155,7 @@
},
"appServer.serviceTier": {
"label": "Service Tier",
"help": "Optional Codex app-server service tier. Use fast, flex, or null.",
"help": "Optional Codex service tier passed when starting or resuming threads.",
"advanced": true
}
}

View File

@@ -1,26 +0,0 @@
import {
GPT5_BEHAVIOR_CONTRACT,
GPT5_FRIENDLY_PROMPT_OVERLAY,
isGpt5ModelId,
renderGpt5PromptOverlay,
resolveGpt5SystemPromptContribution,
} from "openclaw/plugin-sdk/provider-model-shared";
export const CODEX_FRIENDLY_PROMPT_OVERLAY = GPT5_FRIENDLY_PROMPT_OVERLAY;
export const CODEX_GPT5_BEHAVIOR_CONTRACT = GPT5_BEHAVIOR_CONTRACT;
export function shouldApplyCodexPromptOverlay(params: { modelId?: string }): boolean {
return isGpt5ModelId(params.modelId);
}
export function resolveCodexSystemPromptContribution(
params: Parameters<typeof resolveGpt5SystemPromptContribution>[0],
) {
return resolveGpt5SystemPromptContribution(params);
}
export function renderCodexPromptOverlay(
params: Parameters<typeof renderGpt5PromptOverlay>[0],
): string | undefined {
return renderGpt5PromptOverlay(params);
}

View File

@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import {
@@ -177,33 +176,4 @@ describe("codex provider", () => {
mode: "token",
});
});
it("adds the GPT-5 prompt overlay to Codex provider runs", () => {
const provider = buildCodexProvider();
expect(
provider.resolveSystemPromptContribution?.({
provider: "codex",
modelId: "gpt-5.4",
} as never),
).toEqual({
stablePrefix: CODEX_GPT5_BEHAVIOR_CONTRACT,
sectionOverrides: {
interaction_style: expect.stringContaining(
"Quiet monitoring does not satisfy an explicit ongoing-work instruction.",
),
},
});
});
it("does not add the GPT-5 prompt overlay to non-GPT-5 Codex provider runs", () => {
const provider = buildCodexProvider();
expect(
provider.resolveSystemPromptContribution?.({
provider: "codex",
modelId: "o4-mini",
} as never),
).toBeUndefined();
});
});

View File

@@ -10,7 +10,6 @@ import {
type CodexAppServerModel,
type CodexAppServerModelListResult,
} from "./harness.js";
import { resolveCodexSystemPromptContribution } from "./prompt-overlay.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
@@ -100,8 +99,6 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
...(isKnownXHighCodexModel(modelId) ? [{ id: "xhigh" as const }] : []),
],
}),
resolveSystemPromptContribution: ({ config, modelId }) =>
resolveCodexSystemPromptContribution({ config, modelId }),
isModernModelRef: ({ modelId }) => isModernCodexModel(modelId),
};
}

View File

@@ -106,153 +106,6 @@ describe("Codex app-server approval bridge", () => {
);
});
it("fails closed for unsupported native approval methods without requesting plugin approval", async () => {
const params = createParams();
const result = await handleCodexAppServerApprovalRequest({
method: "future/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "future-1",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
});
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(params.onAgentEvent).not.toHaveBeenCalled();
});
it("labels permission approvals explicitly with sanitized permission detail", async () => {
const params = createParams();
mockCallGatewayTool.mockResolvedValueOnce({
id: "plugin:approval-3",
decision: "allow-once",
});
const result = await handleCodexAppServerApprovalRequest({
method: "item/permissions/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "perm-1",
permissions: {
network: { allowHosts: ["example.com", "*.internal"] },
fileSystem: { roots: ["/"], writePaths: ["/home/simone"] },
},
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toEqual({
permissions: {
network: { allowHosts: ["example.com", "*.internal"] },
fileSystem: { roots: ["/"], writePaths: ["/home/simone"] },
},
scope: "turn",
});
expect(mockCallGatewayTool).toHaveBeenCalledWith(
"plugin.approval.request",
expect.any(Object),
expect.objectContaining({
title: "Codex app-server permission approval",
toolName: "codex_permission_approval",
description: expect.stringContaining("Permissions: network, fileSystem"),
}),
{ expectFinal: false },
);
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
const description = (requestPayload as { description: string }).description;
expect(description).toContain("Network allowHosts: example.com, *.internal");
expect(description).toContain("File system roots: /; writePaths: ~");
expect(description).toContain(
"High-risk targets: wildcard hosts, private-network wildcards, filesystem root, home directory",
);
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.not.stringContaining("agent:main:session-1"),
}),
);
});
it("keeps permission detail bounded with truncated and redacted target samples", async () => {
const params = createParams();
mockCallGatewayTool.mockResolvedValueOnce({
id: "plugin:approval-4",
decision: "allow-once",
});
await handleCodexAppServerApprovalRequest({
method: "item/permissions/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "perm-2",
permissions: {
network: {
allowHosts: [
"https://secret-token@example.com/private",
"*.internal",
"very-long-service-name.example.corp",
"third.example.com",
],
},
fileSystem: {
roots: ["/", "/workspace/project", "/Users/simone/Documents"],
readPaths: ["/Users/simone/.ssh/id_rsa", "/etc/hosts", "/var/log/system.log"],
writePaths: ["/tmp/output", "/var/log/app", "/home/simone/private"],
},
},
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
const [, , requestPayload] = mockCallGatewayTool.mock.calls[0] ?? [];
expect(requestPayload).toEqual(
expect.objectContaining({
description: expect.any(String),
}),
);
const description = (requestPayload as { description: string }).description;
expect(description.length).toBeLessThanOrEqual(700);
expect(description).toContain("example.com");
expect(description).not.toContain("secret-token");
expect(description).not.toContain("simone");
expect(description).toContain("*.internal");
expect(description).toContain("/workspace/project");
expect(description).toContain("readPaths: ~/.ssh/id_rsa, /etc/hosts (+1 more)");
expect(description).toContain("writePaths: /tmp/output, /var/log/app (+1 more)");
expect(description).toContain("High-risk targets:");
});
it("ignores approval requests that are missing explicit thread or turn ids", async () => {
const params = createParams();
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
itemId: "cmd-2",
command: "pnpm test",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
});
expect(result).toBeUndefined();
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(params.onAgentEvent).not.toHaveBeenCalled();
});
it("maps app-server approval response families separately", () => {
expect(
buildApprovalResponse(
@@ -281,9 +134,5 @@ describe("Codex app-server approval bridge", () => {
permissions: { network: { allowHosts: ["example.com"] } },
scope: "turn",
});
expect(buildApprovalResponse("future/requestApproval", undefined, "approved-once")).toEqual({
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
});
});
});

Some files were not shown because too many files have changed in this diff Show More