mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 09:41:17 +08:00
Compare commits
4 Commits
codex/remo
...
fix/bundle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4617949fc9 | ||
|
|
ac1e6f0374 | ||
|
|
458fcd97db | ||
|
|
f1ea5b34f3 |
@@ -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.
|
||||
|
||||
2
.github/workflows/auto-response.yml
vendored
2
.github/workflows/auto-response.yml
vendored
@@ -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
|
||||
|
||||
321
.github/workflows/ci.yml
vendored
321
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
39
.github/workflows/install-smoke.yml
vendored
39
.github/workflows/install-smoke.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/labeler.yml
vendored
6
.github/workflows/labeler.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/parity-gate.yml
vendored
10
.github/workflows/parity-gate.yml
vendored
@@ -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 \
|
||||
|
||||
6
.github/workflows/workflow-sanity.yml
vendored
6
.github/workflows/workflow-sanity.yml
vendored
@@ -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
|
||||
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -412,13 +412,8 @@
|
||||
"title": "Sessions",
|
||||
"detailKeys": [
|
||||
"kinds",
|
||||
"label",
|
||||
"agentId",
|
||||
"search",
|
||||
"limit",
|
||||
"activeMinutes",
|
||||
"includeDerivedTitles",
|
||||
"includeLastMessage",
|
||||
"messageLimit"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,14 +23,10 @@ host configuration.
|
||||
|
||||
## Session key shapes (examples)
|
||||
|
||||
Direct messages collapse to the agent’s **main** session by default:
|
||||
Direct messages collapse to the agent’s **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>`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
72
docs/ci.md
72
docs/ci.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 409–515 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"specs": [
|
||||
"@anthropic-ai/sdk@0.81.0",
|
||||
"@aws/bedrock-token-generator@^1.1.0",
|
||||
"@mariozechner/pi-ai@0.68.1"
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
export {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
getCachedIamToken,
|
||||
MANTLE_IAM_TOKEN_MARKER,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
resolveMantleRuntimeBearerToken,
|
||||
} from "./discovery.js";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"specs": ["@mariozechner/pi-ai@0.68.1"]
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"specs": ["@sinclair/typebox@0.34.49"]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"specs": ["@mariozechner/pi-coding-agent@0.68.1", "ws@^8.20.0", "zod@^4.3.6"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user